m3u8 1.1.0 → 1.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ac7ba85dc1681fda7ba574fd13afc5a2a644726beb03cd10d80862a62d996a76
4
- data.tar.gz: 8ec9e60e8ca0a7c81f4fdea78aae937bde641d37baa6984aef90da1c97f6c068
3
+ metadata.gz: bbf61a694bdc2daf585ad9f405be821f09ba53289ef075e4342f267e674eb21b
4
+ data.tar.gz: d599367f1344f9b1ad91c20590bc0b4c57a6d0369c71e3a34fd69325ae0c7f44
5
5
  SHA512:
6
- metadata.gz: ede5d1bfd13f64815f5777485bd1a744ce0e13cecf0b51c9d657b7c2890b34f69e70dfba66f707b66007ca176308d99dba14f7a259dfdab2553e2f35e1199bba
7
- data.tar.gz: fb035f0a70f3f675d1c53b5ef46af63e4dd0fc88dad66666b0010eb66210b48eb7ad3b0748a81f55d8b95f0a8af1fb4492a0467bd8f0469fce5ef93b9cdb2b10
6
+ metadata.gz: 9574b7ac1a95fe6b2fabe0bd28576b2b21713a1bed68e2a18d4ea9f644920a91a08e4696df69a19d7b8392eda87fc8fa14b090338ffebe31524cd5f3f4235539
7
+ data.tar.gz: 2f34f676d931929f6671b4e3b3ba3efc0e49e73fc2dc54f6c3a64f499e8d2c1a20b974fcad3febcb0f17252d6aef1f9fb612b1c1ec9ad1911e41aa28314f8ea1
data/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ **1.3.0**
2
+
3
+ * Added CLI tool (`bin/m3u8`) with `inspect` and `validate` subcommands for inspecting playlist metadata and checking validity from the command line. Supports file arguments and stdin piping.
4
+ * Added `session_keys` convenience accessor to `Playlist`.
5
+
6
+ ***
7
+
8
+ **1.2.0**
9
+
10
+ * Added `Playlist.build` with block-based Builder DSL for concise playlist construction. Supports both `instance_eval` (clean DSL) and yielded builder (outer scope access) forms. All 19 item types have corresponding DSL methods.
11
+
12
+ ***
13
+
1
14
  **1.1.0**
2
15
 
3
16
  * Added convenience accessor methods to `Playlist` for filtering items by type: `segments`, `playlists`, `media_items`, `keys`, `maps`, `date_ranges`, `parts`, `session_data`.
data/README.md CHANGED
@@ -30,6 +30,127 @@ Or install it yourself as:
30
30
 
31
31
  $ gem install m3u8
32
32
 
33
+ ## CLI
34
+
35
+ The gem includes a command-line tool for inspecting and validating playlists.
36
+
37
+ ### Inspect
38
+
39
+ Display playlist metadata and item summary:
40
+
41
+ ```
42
+ $ m3u8 inspect master.m3u8
43
+ Type: Master
44
+ Independent Segments: Yes
45
+
46
+ Variants: 6
47
+ 1920x1080 5042000 bps hls/1080/1080.m3u8
48
+ 640x360 861000 bps hls/360/360.m3u8
49
+ Media: 2
50
+ Session Keys: 1
51
+ Session Data: 0
52
+
53
+ $ m3u8 inspect media.m3u8
54
+ Type: Media
55
+ Version: 4
56
+ Sequence: 1
57
+ Target: 12
58
+ Duration: 1371.99s
59
+ Playlist: VOD
60
+ Cache: No
61
+
62
+ Segments: 138
63
+ Keys: 0
64
+ Maps: 0
65
+ ```
66
+
67
+ Reads from stdin when no file is given:
68
+
69
+ ```
70
+ $ cat playlist.m3u8 | m3u8 inspect
71
+ ```
72
+
73
+ ### Validate
74
+
75
+ Check playlist validity (exit 0 for valid, 1 for invalid):
76
+
77
+ ```
78
+ $ m3u8 validate playlist.m3u8
79
+ Valid
80
+
81
+ $ m3u8 validate bad.m3u8
82
+ Invalid: mixed playlist and segment items
83
+ ```
84
+
85
+ ## Usage (Builder DSL)
86
+
87
+ `Playlist.build` provides a block-based DSL for concise playlist construction. It supports two forms:
88
+
89
+ ```ruby
90
+ # instance_eval form (clean DSL)
91
+ playlist = M3u8::Playlist.build(version: 4, target: 12) do
92
+ segment duration: 11.34, segment: '1080-7mbps00000.ts'
93
+ segment duration: 11.26, segment: '1080-7mbps00001.ts'
94
+ end
95
+
96
+ # yielded builder form (access outer scope)
97
+ playlist = M3u8::Playlist.build(version: 4) do |b|
98
+ files.each { |f| b.segment duration: 10.0, segment: f }
99
+ end
100
+ ```
101
+
102
+ Build a master playlist:
103
+
104
+ ```ruby
105
+ playlist = M3u8::Playlist.build(independent_segments: true) do
106
+ media type: 'AUDIO', group_id: 'audio', name: 'English',
107
+ default: true, uri: 'eng/index.m3u8'
108
+ playlist bandwidth: 5_042_000, width: 1920, height: 1080,
109
+ profile: 'high', level: 4.1, audio_codec: 'aac-lc',
110
+ uri: 'hls/1080.m3u8'
111
+ playlist bandwidth: 2_387_000, width: 1280, height: 720,
112
+ profile: 'main', level: 3.1, audio_codec: 'aac-lc',
113
+ uri: 'hls/720.m3u8'
114
+ end
115
+ ```
116
+
117
+ Build a media playlist:
118
+
119
+ ```ruby
120
+ playlist = M3u8::Playlist.build(version: 4, target: 12,
121
+ sequence: 1, type: 'VOD') do
122
+ key method: 'AES-128', uri: 'https://example.com/key.bin'
123
+ map uri: 'init.mp4'
124
+ segment duration: 11.34, segment: '00000.ts'
125
+ discontinuity
126
+ segment duration: 11.26, segment: '00001.ts'
127
+ end
128
+ ```
129
+
130
+ Build an LL-HLS playlist:
131
+
132
+ ```ruby
133
+ sc = M3u8::ServerControlItem.new(
134
+ can_skip_until: 24.0, part_hold_back: 1.0,
135
+ can_block_reload: true
136
+ )
137
+ pi = M3u8::PartInfItem.new(part_target: 0.5)
138
+
139
+ playlist = M3u8::Playlist.build(
140
+ version: 9, target: 4, sequence: 100,
141
+ server_control: sc, part_inf: pi, live: true
142
+ ) do
143
+ map uri: 'init.mp4'
144
+ segment duration: 4.0, segment: 'seg100.mp4'
145
+ part duration: 0.5, uri: 'seg101.0.mp4', independent: true
146
+ preload_hint type: 'PART', uri: 'seg101.1.mp4'
147
+ rendition_report uri: '../alt/index.m3u8',
148
+ last_msn: 101, last_part: 0
149
+ end
150
+ ```
151
+
152
+ All DSL methods correspond to item classes: `segment`, `playlist`, `media`, `session_data`, `session_key`, `content_steering`, `key`, `map`, `date_range`, `discontinuity`, `gap`, `time`, `bitrate`, `part`, `preload_hint`, `rendition_report`, `skip`, `define`, `playback_start`.
153
+
33
154
  ## Usage (creating playlists)
34
155
 
35
156
  Create a master playlist and add child playlists for adaptive bitrate streaming:
data/bin/m3u8 ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'm3u8'
5
+
6
+ exit M3u8::CLI.run(ARGV, $stdin, $stdout, $stderr)
@@ -0,0 +1,47 @@
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
+ def initialize(playlist)
32
+ @playlist = playlist
33
+ end
34
+
35
+ ITEMS.each do |method_name, class_name|
36
+ define_method(method_name) do |params = {}|
37
+ @playlist.items << M3u8.const_get(class_name).new(params)
38
+ end
39
+ end
40
+
41
+ ZERO_ARG_ITEMS.each do |method_name, class_name|
42
+ define_method(method_name) do
43
+ @playlist.items << M3u8.const_get(class_name).new
44
+ end
45
+ end
46
+ end
47
+ 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,23 @@
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: mixed playlist and segment items'
18
+ 1
19
+ end
20
+ end
21
+ end
22
+ end
23
+ 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
data/lib/m3u8/playlist.rb CHANGED
@@ -14,6 +14,17 @@ module M3u8
14
14
  @items = []
15
15
  end
16
16
 
17
+ def self.build(options = {}, &block)
18
+ playlist = new(options)
19
+ builder = Builder.new(playlist)
20
+ if block.arity == 1
21
+ yield builder
22
+ else
23
+ builder.instance_eval(&block)
24
+ end
25
+ playlist
26
+ end
27
+
17
28
  def self.codecs(options = {})
18
29
  item = PlaylistItem.new(options)
19
30
  item.codecs
@@ -55,35 +66,39 @@ module M3u8
55
66
  end
56
67
 
57
68
  def segments
58
- items.select { |item| item.is_a?(SegmentItem) }
69
+ items.grep(SegmentItem)
59
70
  end
60
71
 
61
72
  def playlists
62
- items.select { |item| item.is_a?(PlaylistItem) }
73
+ items.grep(PlaylistItem)
63
74
  end
64
75
 
65
76
  def media_items
66
- items.select { |item| item.is_a?(MediaItem) }
77
+ items.grep(MediaItem)
67
78
  end
68
79
 
69
80
  def keys
70
- items.select { |item| item.is_a?(KeyItem) }
81
+ items.grep(KeyItem)
71
82
  end
72
83
 
73
84
  def maps
74
- items.select { |item| item.is_a?(MapItem) }
85
+ items.grep(MapItem)
75
86
  end
76
87
 
77
88
  def date_ranges
78
- items.select { |item| item.is_a?(DateRangeItem) }
89
+ items.grep(DateRangeItem)
79
90
  end
80
91
 
81
92
  def parts
82
- items.select { |item| item.is_a?(PartItem) }
93
+ items.grep(PartItem)
83
94
  end
84
95
 
85
96
  def session_data
86
- items.select { |item| item.is_a?(SessionDataItem) }
97
+ items.grep(SessionDataItem)
98
+ end
99
+
100
+ def session_keys
101
+ items.grep(SessionKeyItem)
87
102
  end
88
103
 
89
104
  def duration
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.1.0'
5
+ VERSION = '1.3.0'
6
6
  end
data/lib/m3u8.rb CHANGED
@@ -14,10 +14,8 @@ module M3u8
14
14
  end
15
15
 
16
16
  def parse_attributes(line)
17
- # rubocop:disable Style/HashTransformValues
18
17
  line.delete("\n").scan(/([A-Za-z0-9-]+)\s*=\s*("[^"]*"|[^,]*)/)
19
18
  .to_h { |key, value| [key, value.delete('"')] }
20
- # rubocop:enable Style/HashTransformValues
21
19
  end
22
20
 
23
21
  def parse_float(value)