m3u8 0.8.2 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. checksums.yaml +5 -5
  2. data/.github/workflows/ci.yml +23 -0
  3. data/.gitignore +1 -1
  4. data/.rubocop.yml +31 -0
  5. data/CHANGELOG.md +107 -0
  6. data/Gemfile +7 -0
  7. data/LICENSE.txt +1 -1
  8. data/README.md +524 -40
  9. data/Rakefile +1 -0
  10. data/bin/m3u8 +6 -0
  11. data/lib/m3u8/attribute_formatter.rb +47 -0
  12. data/lib/m3u8/bitrate_item.rb +31 -0
  13. data/lib/m3u8/builder.rb +48 -0
  14. data/lib/m3u8/byte_range.rb +10 -0
  15. data/lib/m3u8/cli/inspect_command.rb +97 -0
  16. data/lib/m3u8/cli/validate_command.rb +24 -0
  17. data/lib/m3u8/cli.rb +116 -0
  18. data/lib/m3u8/codecs.rb +89 -0
  19. data/lib/m3u8/content_steering_item.rb +45 -0
  20. data/lib/m3u8/date_range_item.rb +135 -64
  21. data/lib/m3u8/define_item.rb +54 -0
  22. data/lib/m3u8/discontinuity_item.rb +3 -0
  23. data/lib/m3u8/encryptable.rb +27 -30
  24. data/lib/m3u8/error.rb +1 -0
  25. data/lib/m3u8/gap_item.rb +14 -0
  26. data/lib/m3u8/key_item.rb +7 -0
  27. data/lib/m3u8/map_item.rb +16 -5
  28. data/lib/m3u8/media_item.rb +48 -76
  29. data/lib/m3u8/part_inf_item.rb +35 -0
  30. data/lib/m3u8/part_item.rb +67 -0
  31. data/lib/m3u8/playback_start.rb +19 -12
  32. data/lib/m3u8/playlist.rb +221 -13
  33. data/lib/m3u8/playlist_item.rb +128 -124
  34. data/lib/m3u8/preload_hint_item.rb +54 -0
  35. data/lib/m3u8/reader.rb +86 -28
  36. data/lib/m3u8/rendition_report_item.rb +48 -0
  37. data/lib/m3u8/scte35.rb +130 -0
  38. data/lib/m3u8/scte35_bit_reader.rb +51 -0
  39. data/lib/m3u8/scte35_segmentation_descriptor.rb +54 -0
  40. data/lib/m3u8/scte35_splice_insert.rb +62 -0
  41. data/lib/m3u8/scte35_splice_null.rb +8 -0
  42. data/lib/m3u8/scte35_time_signal.rb +19 -0
  43. data/lib/m3u8/segment_item.rb +37 -3
  44. data/lib/m3u8/server_control_item.rb +69 -0
  45. data/lib/m3u8/session_data_item.rb +17 -28
  46. data/lib/m3u8/session_key_item.rb +8 -1
  47. data/lib/m3u8/skip_item.rb +48 -0
  48. data/lib/m3u8/time_item.rb +10 -0
  49. data/lib/m3u8/version.rb +1 -1
  50. data/lib/m3u8/writer.rb +24 -1
  51. data/lib/m3u8.rb +30 -6
  52. data/m3u8.gemspec +12 -12
  53. data/spec/fixtures/content_steering.m3u8 +10 -0
  54. data/spec/fixtures/daterange_playlist.m3u8 +14 -0
  55. data/spec/fixtures/encrypted_discontinuity.m3u8 +17 -0
  56. data/spec/fixtures/event_playlist.m3u8 +18 -0
  57. data/spec/fixtures/gap_playlist.m3u8 +14 -0
  58. data/spec/fixtures/ll_hls_advanced.m3u8 +18 -0
  59. data/spec/fixtures/ll_hls_playlist.m3u8 +20 -0
  60. data/spec/fixtures/master_full.m3u8 +14 -0
  61. data/spec/fixtures/master_v13.m3u8 +8 -0
  62. data/spec/lib/m3u8/bitrate_item_spec.rb +26 -0
  63. data/spec/lib/m3u8/builder_spec.rb +352 -0
  64. data/spec/lib/m3u8/byte_range_spec.rb +1 -0
  65. data/spec/lib/m3u8/cli/inspect_command_spec.rb +102 -0
  66. data/spec/lib/m3u8/cli/validate_command_spec.rb +39 -0
  67. data/spec/lib/m3u8/cli_spec.rb +104 -0
  68. data/spec/lib/m3u8/content_steering_item_spec.rb +56 -0
  69. data/spec/lib/m3u8/date_range_item_spec.rb +159 -31
  70. data/spec/lib/m3u8/define_item_spec.rb +59 -0
  71. data/spec/lib/m3u8/discontinuity_item_spec.rb +1 -0
  72. data/spec/lib/m3u8/gap_item_spec.rb +12 -0
  73. data/spec/lib/m3u8/key_item_spec.rb +1 -0
  74. data/spec/lib/m3u8/map_item_spec.rb +1 -0
  75. data/spec/lib/m3u8/media_item_spec.rb +34 -0
  76. data/spec/lib/m3u8/part_inf_item_spec.rb +27 -0
  77. data/spec/lib/m3u8/part_item_spec.rb +67 -0
  78. data/spec/lib/m3u8/playback_start_spec.rb +4 -5
  79. data/spec/lib/m3u8/playlist_item_spec.rb +130 -17
  80. data/spec/lib/m3u8/playlist_spec.rb +545 -13
  81. data/spec/lib/m3u8/preload_hint_item_spec.rb +57 -0
  82. data/spec/lib/m3u8/reader_spec.rb +376 -29
  83. data/spec/lib/m3u8/rendition_report_item_spec.rb +56 -0
  84. data/spec/lib/m3u8/round_trip_spec.rb +152 -0
  85. data/spec/lib/m3u8/scte35_bit_reader_spec.rb +106 -0
  86. data/spec/lib/m3u8/scte35_segmentation_descriptor_spec.rb +143 -0
  87. data/spec/lib/m3u8/scte35_spec.rb +94 -0
  88. data/spec/lib/m3u8/scte35_splice_insert_spec.rb +185 -0
  89. data/spec/lib/m3u8/scte35_splice_null_spec.rb +12 -0
  90. data/spec/lib/m3u8/scte35_time_signal_spec.rb +50 -0
  91. data/spec/lib/m3u8/segment_item_spec.rb +47 -0
  92. data/spec/lib/m3u8/server_control_item_spec.rb +64 -0
  93. data/spec/lib/m3u8/session_data_item_spec.rb +1 -0
  94. data/spec/lib/m3u8/session_key_item_spec.rb +1 -0
  95. data/spec/lib/m3u8/skip_item_spec.rb +48 -0
  96. data/spec/lib/m3u8/time_item_spec.rb +1 -0
  97. data/spec/lib/m3u8/writer_spec.rb +69 -30
  98. data/spec/lib/m3u8_spec.rb +1 -0
  99. data/spec/spec_helper.rb +4 -87
  100. metadata +70 -129
  101. data/.hound.yml +0 -3
  102. data/.travis.yml +0 -8
  103. data/Guardfile +0 -6
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module M3u8
4
+ # BitrateItem represents an EXT-X-BITRATE tag that indicates the
5
+ # approximate bitrate of the following media segments in kbps.
6
+ class BitrateItem
7
+ # @return [Integer, nil] approximate bitrate in kbps
8
+ attr_accessor :bitrate
9
+
10
+ # @param params [Hash] attribute key-value pairs
11
+ def initialize(params = {})
12
+ params.each do |key, value|
13
+ instance_variable_set("@#{key}", value)
14
+ end
15
+ end
16
+
17
+ # Parse an EXT-X-BITRATE tag.
18
+ # @param text [String] raw tag line
19
+ # @return [BitrateItem]
20
+ def self.parse(text)
21
+ value = text.gsub('#EXT-X-BITRATE:', '').strip
22
+ BitrateItem.new(bitrate: value.to_i)
23
+ end
24
+
25
+ # Render as an m3u8 EXT-X-BITRATE tag.
26
+ # @return [String]
27
+ def to_s
28
+ "#EXT-X-BITRATE:#{bitrate}"
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module M3u8
4
+ # Builder provides a block-based DSL for constructing playlists
5
+ class Builder
6
+ ITEMS = {
7
+ segment: 'SegmentItem',
8
+ playlist: 'PlaylistItem',
9
+ media: 'MediaItem',
10
+ session_data: 'SessionDataItem',
11
+ session_key: 'SessionKeyItem',
12
+ content_steering: 'ContentSteeringItem',
13
+ key: 'KeyItem',
14
+ map: 'MapItem',
15
+ date_range: 'DateRangeItem',
16
+ time: 'TimeItem',
17
+ bitrate: 'BitrateItem',
18
+ part: 'PartItem',
19
+ preload_hint: 'PreloadHintItem',
20
+ rendition_report: 'RenditionReportItem',
21
+ skip: 'SkipItem',
22
+ define: 'DefineItem',
23
+ playback_start: 'PlaybackStart'
24
+ }.freeze
25
+
26
+ ZERO_ARG_ITEMS = {
27
+ discontinuity: 'DiscontinuityItem',
28
+ gap: 'GapItem'
29
+ }.freeze
30
+
31
+ # @param playlist [Playlist] playlist to build into
32
+ def initialize(playlist)
33
+ @playlist = playlist
34
+ end
35
+
36
+ ITEMS.each do |method_name, class_name|
37
+ define_method(method_name) do |params = {}|
38
+ @playlist.items << M3u8.const_get(class_name).new(params)
39
+ end
40
+ end
41
+
42
+ ZERO_ARG_ITEMS.each do |method_name, class_name|
43
+ define_method(method_name) do
44
+ @playlist.items << M3u8.const_get(class_name).new
45
+ end
46
+ end
47
+ end
48
+ end
@@ -1,15 +1,22 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module M3u8
3
4
  # ByteRange represents sub range of a resource
4
5
  class ByteRange
6
+ # @return [Integer, nil] number of bytes
7
+ # @return [Integer, nil] start offset in bytes
5
8
  attr_accessor :length, :start
6
9
 
10
+ # @param params [Hash] :length and optional :start
7
11
  def initialize(params = {})
8
12
  params.each do |key, value|
9
13
  instance_variable_set("@#{key}", value)
10
14
  end
11
15
  end
12
16
 
17
+ # Parse a byte range string (e.g. "4500@600").
18
+ # @param text [String] byte range string
19
+ # @return [ByteRange]
13
20
  def self.parse(text)
14
21
  values = text.split('@')
15
22
  length_value = values[0].to_i
@@ -18,6 +25,8 @@ module M3u8
18
25
  ByteRange.new(options)
19
26
  end
20
27
 
28
+ # Render as a byte range string (e.g. "4500@600").
29
+ # @return [String]
21
30
  def to_s
22
31
  "#{length}#{start_format}"
23
32
  end
@@ -26,6 +35,7 @@ module M3u8
26
35
 
27
36
  def start_format
28
37
  return if start.nil?
38
+
29
39
  "@#{start}"
30
40
  end
31
41
  end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module M3u8
4
+ class CLI
5
+ # InspectCommand displays metadata about a playlist
6
+ class InspectCommand
7
+ MEDIA_WIDTH = 12
8
+ MASTER_WIDTH = 23
9
+
10
+ def initialize(playlist, stdout)
11
+ @playlist = playlist
12
+ @stdout = stdout
13
+ end
14
+
15
+ def run
16
+ if @playlist.master?
17
+ print_master
18
+ else
19
+ print_media
20
+ end
21
+ 0
22
+ end
23
+
24
+ private
25
+
26
+ def print_media
27
+ field 'Type', 'Media', MEDIA_WIDTH
28
+ field 'Version', @playlist.version, MEDIA_WIDTH
29
+ field 'Sequence', @playlist.sequence, MEDIA_WIDTH
30
+ field 'Target', @playlist.target, MEDIA_WIDTH
31
+ field 'Duration', duration_value, MEDIA_WIDTH
32
+ field 'Playlist', @playlist.type, MEDIA_WIDTH
33
+ field 'Cache', cache_value, MEDIA_WIDTH
34
+ @stdout.puts
35
+ field 'Segments', @playlist.segments.size, MEDIA_WIDTH
36
+ field 'Keys', @playlist.keys.size, MEDIA_WIDTH
37
+ field 'Maps', @playlist.maps.size, MEDIA_WIDTH
38
+ end
39
+
40
+ def print_master
41
+ field 'Type', 'Master', MASTER_WIDTH
42
+ field 'Independent Segments',
43
+ independent_segments_value, MASTER_WIDTH
44
+ @stdout.puts
45
+ print_variants
46
+ print_media_items
47
+ field 'Session Keys',
48
+ @playlist.session_keys.size, MASTER_WIDTH
49
+ field 'Session Data',
50
+ @playlist.session_data.size, MASTER_WIDTH
51
+ end
52
+
53
+ def print_variants
54
+ variants = @playlist.playlists
55
+ field 'Variants', variants.size, MASTER_WIDTH
56
+ variants.each { |v| @stdout.puts variant_line(v) }
57
+ end
58
+
59
+ def print_media_items
60
+ items = @playlist.media_items
61
+ field 'Media', items.size, MASTER_WIDTH
62
+ items.each do |m|
63
+ @stdout.puts " #{m.type} #{m.group_id} #{m.name}"
64
+ end
65
+ end
66
+
67
+ def variant_line(variant)
68
+ res = variant.resolution || ''
69
+ format(' %-11<res>s%<bw>s bps %<uri>s',
70
+ res: res, bw: variant.bandwidth, uri: variant.uri)
71
+ end
72
+
73
+ def independent_segments_value
74
+ return unless @playlist.independent_segments
75
+
76
+ 'Yes'
77
+ end
78
+
79
+ def duration_value
80
+ format('%<s>gs', s: @playlist.duration)
81
+ end
82
+
83
+ def cache_value
84
+ return unless @playlist.cache == false
85
+
86
+ 'No'
87
+ end
88
+
89
+ def field(label, value, width)
90
+ return if value.nil?
91
+
92
+ @stdout.puts format("%-#{width}<label>s%<value>s",
93
+ label: "#{label}:", value: value)
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module M3u8
4
+ class CLI
5
+ # ValidateCommand checks playlist validity
6
+ class ValidateCommand
7
+ def initialize(playlist, stdout)
8
+ @playlist = playlist
9
+ @stdout = stdout
10
+ end
11
+
12
+ def run
13
+ if @playlist.valid?
14
+ @stdout.puts 'Valid'
15
+ 0
16
+ else
17
+ @stdout.puts 'Invalid'
18
+ @playlist.errors.each { |e| @stdout.puts " - #{e}" }
19
+ 1
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
data/lib/m3u8/cli.rb ADDED
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require_relative 'cli/inspect_command'
5
+ require_relative 'cli/validate_command'
6
+
7
+ module M3u8
8
+ # CLI provides a command-line interface for inspecting and validating
9
+ # m3u8 playlists
10
+ class CLI
11
+ COMMANDS = %w[inspect validate].freeze
12
+
13
+ def self.run(argv, stdin, stdout, stderr)
14
+ new(argv, stdin, stdout, stderr).run
15
+ end
16
+
17
+ def initialize(argv, stdin, stdout, stderr)
18
+ @argv = argv.dup
19
+ @stdin = stdin
20
+ @stdout = stdout
21
+ @stderr = stderr
22
+ end
23
+
24
+ def run
25
+ parse_global_options
26
+ dispatch
27
+ rescue OptionParser::InvalidOption => e
28
+ @stderr.puts e.message
29
+ 2
30
+ end
31
+
32
+ private
33
+
34
+ def parse_global_options
35
+ @parser = OptionParser.new do |opts|
36
+ opts.banner = 'Usage: m3u8 <command> [options] [file]'
37
+ opts.separator ''
38
+ opts.separator 'Commands:'
39
+ opts.separator ' inspect Show playlist metadata'
40
+ opts.separator ' validate Check playlist validity'
41
+ opts.separator ''
42
+ opts.on('-v', '--version', 'Show version') do
43
+ @stdout.puts M3u8::VERSION
44
+ throw :exit, 0
45
+ end
46
+ opts.on('-h', '--help', 'Show help') do
47
+ @stdout.puts opts
48
+ throw :exit, 0
49
+ end
50
+ end
51
+
52
+ @exit_code = catch(:exit) do
53
+ @parser.order!(@argv)
54
+ nil
55
+ end
56
+ end
57
+
58
+ def dispatch
59
+ return @exit_code if @exit_code
60
+
61
+ command = @argv.shift
62
+ return usage_error if command.nil?
63
+ return usage_error("unknown command: #{command}") \
64
+ unless COMMANDS.include?(command)
65
+
66
+ input = resolve_input
67
+ return 2 unless input
68
+
69
+ playlist = parse_playlist(input)
70
+ return 2 unless playlist
71
+
72
+ execute_command(command, playlist)
73
+ end
74
+
75
+ def execute_command(command, playlist)
76
+ case command
77
+ when 'inspect'
78
+ InspectCommand.new(playlist, @stdout).run
79
+ when 'validate'
80
+ ValidateCommand.new(playlist, @stdout).run
81
+ end
82
+ end
83
+
84
+ def resolve_input
85
+ file = @argv.shift
86
+ if file
87
+ read_file(file)
88
+ elsif !@stdin.tty?
89
+ @stdin.read
90
+ else
91
+ usage_error
92
+ nil
93
+ end
94
+ end
95
+
96
+ def read_file(path)
97
+ File.read(path)
98
+ rescue Errno::ENOENT
99
+ @stderr.puts "no such file: #{path}"
100
+ nil
101
+ end
102
+
103
+ def parse_playlist(input)
104
+ Playlist.read(input)
105
+ rescue StandardError => e
106
+ @stderr.puts "parse error: #{e.message}"
107
+ nil
108
+ end
109
+
110
+ def usage_error(message = nil)
111
+ @stderr.puts message if message
112
+ @stderr.puts @parser.to_s
113
+ 2
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module M3u8
4
+ # Codec lookup tables for HLS playlist items
5
+ module Codecs
6
+ AUDIO_CODECS = {
7
+ 'aac-lc' => 'mp4a.40.2',
8
+ 'he-aac' => 'mp4a.40.5',
9
+ 'mp3' => 'mp4a.40.34',
10
+ 'ac-3' => 'ac-3',
11
+ 'ec-3' => 'ec-3',
12
+ 'e-ac-3' => 'ec-3',
13
+ 'flac' => 'fLaC',
14
+ 'opus' => 'Opus'
15
+ }.freeze
16
+
17
+ BASELINE_CODECS = {
18
+ 3.0 => 'avc1.66.30',
19
+ 3.1 => 'avc1.42001f'
20
+ }.freeze
21
+
22
+ MAIN_CODECS = {
23
+ 3.0 => 'avc1.77.30',
24
+ 3.1 => 'avc1.4d001f',
25
+ 4.0 => 'avc1.4d0028',
26
+ 4.1 => 'avc1.4d0029'
27
+ }.freeze
28
+
29
+ HIGH_LEVELS = [3.0, 3.1, 3.2, 4.0, 4.1, 4.2,
30
+ 5.0, 5.1, 5.2].freeze
31
+
32
+ HEVC_CODECS = {
33
+ ['hevc-main', 3.1] => 'hvc1.1.6.L93.B0',
34
+ ['hevc-main', 4.0] => 'hvc1.1.6.L120.B0',
35
+ ['hevc-main', 5.0] => 'hvc1.1.6.L150.B0',
36
+ ['hevc-main', 5.1] => 'hvc1.1.6.L153.B0',
37
+ ['hevc-main-10', 3.1] => 'hvc1.2.4.L93.B0',
38
+ ['hevc-main-10', 4.0] => 'hvc1.2.4.L120.B0',
39
+ ['hevc-main-10', 5.0] => 'hvc1.2.4.L150.B0',
40
+ ['hevc-main-10', 5.1] => 'hvc1.2.4.L153.B0'
41
+ }.freeze
42
+
43
+ AV1_CODECS = {
44
+ ['av1-main', 3.1] => 'av01.0.04M.08',
45
+ ['av1-main', 4.0] => 'av01.0.08M.08',
46
+ ['av1-main', 5.0] => 'av01.0.12M.08',
47
+ ['av1-main', 5.1] => 'av01.0.13M.08',
48
+ ['av1-high', 3.1] => 'av01.1.04H.10',
49
+ ['av1-high', 4.0] => 'av01.1.08H.10',
50
+ ['av1-high', 5.0] => 'av01.1.12H.10',
51
+ ['av1-high', 5.1] => 'av01.1.13H.10'
52
+ }.freeze
53
+
54
+ # Look up the codec string for an audio codec name.
55
+ # @param codec [String, nil] audio codec name
56
+ # @return [String, nil] codec string
57
+ def self.audio_codec(codec)
58
+ return if codec.nil?
59
+
60
+ AUDIO_CODECS[codec.downcase]
61
+ end
62
+
63
+ # Look up the codec string for a video profile and level.
64
+ # @param profile [String, nil] video profile name
65
+ # @param level [Float, Integer, nil] video level
66
+ # @return [String, nil] codec string
67
+ def self.video_codec(profile, level)
68
+ return if profile.nil? || level.nil?
69
+
70
+ level = level.to_f
71
+ name = profile.downcase
72
+ return BASELINE_CODECS[level] if name == 'baseline'
73
+ return MAIN_CODECS[level] if name == 'main'
74
+ return high_codec_string(level) if name == 'high'
75
+ return HEVC_CODECS[[profile, level]] if name.start_with?('hevc-')
76
+
77
+ AV1_CODECS[[profile, level]] if name.start_with?('av1-')
78
+ end
79
+
80
+ def self.high_codec_string(level)
81
+ return nil unless HIGH_LEVELS.include?(level)
82
+
83
+ hex = level.to_s.sub('.', '').to_i.to_s(16)
84
+ "avc1.6400#{hex}"
85
+ end
86
+
87
+ private_class_method :high_codec_string
88
+ end
89
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module M3u8
4
+ # ContentSteeringItem represents an EXT-X-CONTENT-STEERING tag which
5
+ # indicates a Content Steering Manifest for dynamic pathway selection.
6
+ class ContentSteeringItem
7
+ extend M3u8
8
+ include AttributeFormatter
9
+
10
+ # @return [String, nil] steering manifest server URI
11
+ # @return [String, nil] default pathway ID
12
+ attr_accessor :server_uri, :pathway_id
13
+
14
+ # @param params [Hash] attribute key-value pairs
15
+ def initialize(params = {})
16
+ params.each do |key, value|
17
+ instance_variable_set("@#{key}", value)
18
+ end
19
+ end
20
+
21
+ # Parse an EXT-X-CONTENT-STEERING tag.
22
+ # @param text [String] raw tag line
23
+ # @return [ContentSteeringItem]
24
+ def self.parse(text)
25
+ attributes = parse_attributes(text)
26
+ ContentSteeringItem.new(
27
+ server_uri: attributes['SERVER-URI'],
28
+ pathway_id: attributes['PATHWAY-ID']
29
+ )
30
+ end
31
+
32
+ # Render as an m3u8 EXT-X-CONTENT-STEERING tag.
33
+ # @return [String]
34
+ def to_s
35
+ "#EXT-X-CONTENT-STEERING:#{formatted_attributes}"
36
+ end
37
+
38
+ private
39
+
40
+ def formatted_attributes
41
+ [quoted_format('SERVER-URI', server_uri),
42
+ quoted_format('PATHWAY-ID', pathway_id)].compact.join(',')
43
+ end
44
+ end
45
+ end