m3u8 1.2.0 → 1.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7a9a7d6e157afd5fd7028323544bf9d851bd898890f90c66af6f64e672d7e456
4
- data.tar.gz: ee452e25dfb96ee9634e9a00c287a87ac4640dceb2d965550e31199e0c0b4e26
3
+ metadata.gz: 7112d267f1f0b5e6f89f4c5b54392b02dc3d37d39d9cbda88d2125b69b89ca37
4
+ data.tar.gz: 6755f10b8de1fbb8d06f881b29973623fa48e53347b54f3093c37ff794817090
5
5
  SHA512:
6
- metadata.gz: f6b458aaf9a1a1060d19dca456020b54813f0c1f011615593e088a512a87ed32120cd5d509fb66dd3b12f85fe11a255304eede7852a00197cfd9513c918a33eb
7
- data.tar.gz: 7c543fd8e6004adaf275adfe95c231b42fe0549805436f8dea3e77f2731f273084c99887781d0b69daf05ec1e53218fb8711be895fc0c14e9f0687f983a611b1
6
+ metadata.gz: 635a0727ca10232fd961568d72210bbf96ed1a52c64ed0326a98f275ed1948f2d6016abd1edc774972365f459508e92bd0048e5fa511e4864683d196a2eaddbe
7
+ data.tar.gz: 6afceffd0ce2b396737e1963ce89b7d1e31177d12d1226c4f705feff4e3625110de05fb108973eac0203566e018f9aa8ac470a0825ee7c581b86753687320009
data/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ **1.3.1**
2
+
3
+ * Excluded CLAUDE.md and AGENTS.md from gem package.
4
+
5
+ ***
6
+
7
+ **1.3.0**
8
+
9
+ * 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.
10
+ * Added `session_keys` convenience accessor to `Playlist`.
11
+
12
+ ***
13
+
1
14
  **1.2.0**
2
15
 
3
16
  * 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.
data/README.md CHANGED
@@ -30,6 +30,58 @@ 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
+
33
85
  ## Usage (Builder DSL)
34
86
 
35
87
  `Playlist.build` provides a block-based DSL for concise playlist construction. It supports two forms:
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,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
@@ -66,35 +66,39 @@ module M3u8
66
66
  end
67
67
 
68
68
  def segments
69
- items.select { |item| item.is_a?(SegmentItem) }
69
+ items.grep(SegmentItem)
70
70
  end
71
71
 
72
72
  def playlists
73
- items.select { |item| item.is_a?(PlaylistItem) }
73
+ items.grep(PlaylistItem)
74
74
  end
75
75
 
76
76
  def media_items
77
- items.select { |item| item.is_a?(MediaItem) }
77
+ items.grep(MediaItem)
78
78
  end
79
79
 
80
80
  def keys
81
- items.select { |item| item.is_a?(KeyItem) }
81
+ items.grep(KeyItem)
82
82
  end
83
83
 
84
84
  def maps
85
- items.select { |item| item.is_a?(MapItem) }
85
+ items.grep(MapItem)
86
86
  end
87
87
 
88
88
  def date_ranges
89
- items.select { |item| item.is_a?(DateRangeItem) }
89
+ items.grep(DateRangeItem)
90
90
  end
91
91
 
92
92
  def parts
93
- items.select { |item| item.is_a?(PartItem) }
93
+ items.grep(PartItem)
94
94
  end
95
95
 
96
96
  def session_data
97
- items.select { |item| item.is_a?(SessionDataItem) }
97
+ items.grep(SessionDataItem)
98
+ end
99
+
100
+ def session_keys
101
+ items.grep(SessionKeyItem)
98
102
  end
99
103
 
100
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.2.0'
5
+ VERSION = '1.3.1'
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)
data/m3u8.gemspec CHANGED
@@ -15,6 +15,7 @@ Gem::Specification.new do |spec|
15
15
  spec.required_ruby_version = '>= 3.0'
16
16
 
17
17
  spec.files = `git ls-files -z`.split("\x0")
18
+ .grep_v(/\A(CLAUDE|AGENTS)\.md\z/)
18
19
  spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
19
20
  spec.require_paths = ['lib']
20
21
 
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe M3u8::CLI::InspectCommand do
6
+ let(:stdout) { StringIO.new }
7
+
8
+ def inspect_fixture(name)
9
+ playlist = M3u8::Playlist.read(
10
+ File.read("spec/fixtures/#{name}")
11
+ )
12
+ described_class.new(playlist, stdout).run
13
+ end
14
+
15
+ describe 'media playlist' do
16
+ it 'displays metadata for a VOD playlist' do
17
+ code = inspect_fixture('playlist.m3u8')
18
+ expect(code).to eq(0)
19
+ lines = stdout.string
20
+ expect(lines).to include('Type: Media')
21
+ expect(lines).to include('Version: 4')
22
+ expect(lines).to include('Sequence: 1')
23
+ expect(lines).to include('Target: 12')
24
+ expect(lines).to include('Duration: 1371.99s')
25
+ expect(lines).to include('Playlist: VOD')
26
+ expect(lines).to include('Cache: No')
27
+ expect(lines).to include('Segments: 138')
28
+ expect(lines).to include('Keys: 0')
29
+ expect(lines).to include('Maps: 0')
30
+ end
31
+
32
+ it 'displays metadata for an encrypted playlist' do
33
+ code = inspect_fixture('encrypted.m3u8')
34
+ expect(code).to eq(0)
35
+ lines = stdout.string
36
+ expect(lines).to include('Type: Media')
37
+ expect(lines).to include('Version: 3')
38
+ expect(lines).to include('Sequence: 7794')
39
+ expect(lines).to include('Target: 15')
40
+ expect(lines).to include('Segments: 4')
41
+ expect(lines).to include('Keys: 2')
42
+ expect(lines).not_to include('Playlist:')
43
+ expect(lines).not_to include('Cache:')
44
+ end
45
+
46
+ it 'displays metadata for an LL-HLS playlist' do
47
+ code = inspect_fixture('ll_hls_playlist.m3u8')
48
+ expect(code).to eq(0)
49
+ lines = stdout.string
50
+ expect(lines).to include('Type: Media')
51
+ expect(lines).to include('Version: 9')
52
+ expect(lines).to include('Segments: 2')
53
+ expect(lines).to include('Maps: 1')
54
+ end
55
+ end
56
+
57
+ describe 'master playlist' do
58
+ it 'displays metadata for a master playlist' do
59
+ code = inspect_fixture('master.m3u8')
60
+ expect(code).to eq(0)
61
+ lines = stdout.string
62
+ expect(lines).to include('Type: Master')
63
+ expect(lines).to include(
64
+ 'Independent Segments: Yes'
65
+ )
66
+ expect(lines).to include('Variants: 6')
67
+ expect(lines).to include('1920x1080 5042000 bps')
68
+ expect(lines).to include(
69
+ 'hls/1080-7mbps/1080-7mbps.m3u8'
70
+ )
71
+ expect(lines).to include('6400 bps')
72
+ expect(lines).to include('hls/64k/64k.m3u8')
73
+ expect(lines).to include('Media: 0')
74
+ expect(lines).to include('Session Keys: 1')
75
+ expect(lines).to include('Session Data: 0')
76
+ end
77
+
78
+ it 'displays metadata for a variant audio playlist' do
79
+ code = inspect_fixture('variant_audio.m3u8')
80
+ expect(code).to eq(0)
81
+ lines = stdout.string
82
+ expect(lines).to include('Type: Master')
83
+ expect(lines).to include('Variants: 4')
84
+ expect(lines).to include('Media: 6')
85
+ expect(lines).to include('AUDIO audio-lo English')
86
+ expect(lines).to include(
87
+ "AUDIO audio-hi Fran\xC3\xA7ais"
88
+ )
89
+ expect(lines).to include('Session Keys: 0')
90
+ expect(lines).to include('Session Data: 0')
91
+ expect(lines).not_to include('Independent Segments:')
92
+ end
93
+
94
+ it 'displays session data for a session data playlist' do
95
+ code = inspect_fixture('session_data.m3u8')
96
+ expect(code).to eq(0)
97
+ lines = stdout.string
98
+ expect(lines).to include('Type: Media')
99
+ expect(lines).to include('Segments: 0')
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe M3u8::CLI::ValidateCommand do
6
+ let(:stdout) { StringIO.new }
7
+
8
+ describe '#run' do
9
+ context 'when playlist is valid' do
10
+ it 'prints Valid and returns 0' do
11
+ playlist = M3u8::Playlist.read(
12
+ File.read('spec/fixtures/master.m3u8')
13
+ )
14
+ code = described_class.new(playlist, stdout).run
15
+ expect(code).to eq(0)
16
+ expect(stdout.string.strip).to eq('Valid')
17
+ end
18
+ end
19
+
20
+ context 'when playlist is invalid' do
21
+ it 'prints Invalid and returns 1' do
22
+ playlist = M3u8::Playlist.new
23
+ playlist.items << M3u8::PlaylistItem.new(
24
+ bandwidth: 540, uri: 'test.url'
25
+ )
26
+ playlist.items << M3u8::SegmentItem.new(
27
+ duration: 10.0, segment: 'test.ts'
28
+ )
29
+ code = described_class.new(playlist, stdout).run
30
+ expect(code).to eq(1)
31
+ expect(stdout.string.strip).to include('Invalid')
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe M3u8::CLI do
6
+ let(:stdout) { StringIO.new }
7
+ let(:stderr) { StringIO.new }
8
+ let(:stdin) { StringIO.new }
9
+
10
+ def run(argv)
11
+ described_class.run(argv, stdin, stdout, stderr)
12
+ end
13
+
14
+ describe '--version' do
15
+ it 'prints the version and exits 0' do
16
+ expect(run(['--version'])).to eq(0)
17
+ expect(stdout.string.strip).to eq(M3u8::VERSION)
18
+ end
19
+ end
20
+
21
+ describe '--help' do
22
+ it 'prints usage and exits 0' do
23
+ expect(run(['--help'])).to eq(0)
24
+ expect(stdout.string).to include('Usage: m3u8')
25
+ expect(stdout.string).to include('inspect')
26
+ expect(stdout.string).to include('validate')
27
+ end
28
+ end
29
+
30
+ describe 'no command' do
31
+ it 'prints usage to stderr and exits 2' do
32
+ expect(run([])).to eq(2)
33
+ expect(stderr.string).to include('Usage: m3u8')
34
+ end
35
+ end
36
+
37
+ describe 'unknown command' do
38
+ it 'prints error and usage to stderr and exits 2' do
39
+ expect(run(['bogus'])).to eq(2)
40
+ expect(stderr.string).to include('unknown command: bogus')
41
+ end
42
+ end
43
+
44
+ describe 'invalid option' do
45
+ it 'prints error to stderr and exits 2' do
46
+ expect(run(['--bogus'])).to eq(2)
47
+ expect(stderr.string).to include('invalid option')
48
+ end
49
+ end
50
+
51
+ describe 'file not found' do
52
+ it 'prints error to stderr and exits 2' do
53
+ expect(run(['inspect', 'nonexistent.m3u8'])).to eq(2)
54
+ expect(stderr.string).to include('no such file')
55
+ end
56
+ end
57
+
58
+ describe 'file input' do
59
+ it 'reads a playlist from a file for inspect' do
60
+ code = run(['inspect', 'spec/fixtures/master.m3u8'])
61
+ expect(code).to eq(0)
62
+ end
63
+
64
+ it 'reads a playlist from a file for validate' do
65
+ code = run(['validate', 'spec/fixtures/master.m3u8'])
66
+ expect(code).to eq(0)
67
+ expect(stdout.string.strip).to eq('Valid')
68
+ end
69
+ end
70
+
71
+ describe 'parse error' do
72
+ it 'prints error to stderr and exits 2' do
73
+ stdin = StringIO.new('not a playlist')
74
+ code = described_class.run(
75
+ ['inspect'], stdin, stdout, stderr
76
+ )
77
+ expect(code).to eq(2)
78
+ expect(stderr.string).to include('parse error')
79
+ end
80
+ end
81
+
82
+ describe 'stdin input' do
83
+ it 'reads a playlist from stdin' do
84
+ content = File.read('spec/fixtures/master.m3u8')
85
+ stdin = StringIO.new(content)
86
+ code = described_class.run(
87
+ ['inspect'], stdin, stdout, stderr
88
+ )
89
+ expect(code).to eq(0)
90
+ end
91
+ end
92
+
93
+ describe 'command with no input on a tty' do
94
+ it 'prints usage to stderr and exits 2' do
95
+ tty = StringIO.new
96
+ allow(tty).to receive(:tty?).and_return(true)
97
+ code = described_class.run(
98
+ ['inspect'], tty, stdout, stderr
99
+ )
100
+ expect(code).to eq(2)
101
+ expect(stderr.string).to include('Usage: m3u8')
102
+ end
103
+ end
104
+ end
@@ -60,8 +60,9 @@ describe M3u8::Playlist do
60
60
 
61
61
  describe '.read' do
62
62
  it 'returns new playlist from content' do
63
- file = File.open('spec/fixtures/master.m3u8')
64
- playlist = described_class.read(file)
63
+ playlist = described_class.read(
64
+ File.read('spec/fixtures/master.m3u8')
65
+ )
65
66
  expect(playlist.master?).to be true
66
67
  expect(playlist.items.size).to eq(8)
67
68
  end
@@ -238,8 +239,9 @@ describe M3u8::Playlist do
238
239
 
239
240
  describe '#segments' do
240
241
  it 'returns only segment items' do
241
- file = File.open('spec/fixtures/playlist.m3u8')
242
- playlist = described_class.read(file)
242
+ playlist = described_class.read(
243
+ File.read('spec/fixtures/playlist.m3u8')
244
+ )
243
245
  expect(playlist.segments).to all be_a(M3u8::SegmentItem)
244
246
  expect(playlist.segments.size).to eq(138)
245
247
  end
@@ -247,8 +249,9 @@ describe M3u8::Playlist do
247
249
 
248
250
  describe '#playlists' do
249
251
  it 'returns only playlist items' do
250
- file = File.open('spec/fixtures/master.m3u8')
251
- playlist = described_class.read(file)
252
+ playlist = described_class.read(
253
+ File.read('spec/fixtures/master.m3u8')
254
+ )
252
255
  expect(playlist.playlists).to all be_a(M3u8::PlaylistItem)
253
256
  expect(playlist.playlists.size).to eq(6)
254
257
  end
@@ -256,8 +259,9 @@ describe M3u8::Playlist do
256
259
 
257
260
  describe '#media_items' do
258
261
  it 'returns only media items' do
259
- file = File.open('spec/fixtures/variant_audio.m3u8')
260
- playlist = described_class.read(file)
262
+ playlist = described_class.read(
263
+ File.read('spec/fixtures/variant_audio.m3u8')
264
+ )
261
265
  expect(playlist.media_items).to all be_a(M3u8::MediaItem)
262
266
  expect(playlist.media_items.size).to eq(6)
263
267
  end
@@ -265,8 +269,9 @@ describe M3u8::Playlist do
265
269
 
266
270
  describe '#keys' do
267
271
  it 'returns only key items' do
268
- file = File.open('spec/fixtures/encrypted.m3u8')
269
- playlist = described_class.read(file)
272
+ playlist = described_class.read(
273
+ File.read('spec/fixtures/encrypted.m3u8')
274
+ )
270
275
  expect(playlist.keys).to all be_a(M3u8::KeyItem)
271
276
  expect(playlist.keys.size).to eq(2)
272
277
  end
@@ -274,8 +279,9 @@ describe M3u8::Playlist do
274
279
 
275
280
  describe '#maps' do
276
281
  it 'returns only map items' do
277
- file = File.open('spec/fixtures/map_playlist.m3u8')
278
- playlist = described_class.read(file)
282
+ playlist = described_class.read(
283
+ File.read('spec/fixtures/map_playlist.m3u8')
284
+ )
279
285
  expect(playlist.maps).to all be_a(M3u8::MapItem)
280
286
  expect(playlist.maps.size).to eq(1)
281
287
  end
@@ -283,8 +289,9 @@ describe M3u8::Playlist do
283
289
 
284
290
  describe '#date_ranges' do
285
291
  it 'returns only date range items' do
286
- file = File.open('spec/fixtures/daterange_playlist.m3u8')
287
- playlist = described_class.read(file)
292
+ playlist = described_class.read(
293
+ File.read('spec/fixtures/daterange_playlist.m3u8')
294
+ )
288
295
  expect(playlist.date_ranges)
289
296
  .to all be_a(M3u8::DateRangeItem)
290
297
  expect(playlist.date_ranges.size).to eq(3)
@@ -293,8 +300,9 @@ describe M3u8::Playlist do
293
300
 
294
301
  describe '#parts' do
295
302
  it 'returns only part items' do
296
- file = File.open('spec/fixtures/ll_hls_playlist.m3u8')
297
- playlist = described_class.read(file)
303
+ playlist = described_class.read(
304
+ File.read('spec/fixtures/ll_hls_playlist.m3u8')
305
+ )
298
306
  expect(playlist.parts).to all be_a(M3u8::PartItem)
299
307
  expect(playlist.parts.size).to eq(5)
300
308
  end
@@ -302,14 +310,26 @@ describe M3u8::Playlist do
302
310
 
303
311
  describe '#session_data' do
304
312
  it 'returns only session data items' do
305
- file = File.open('spec/fixtures/session_data.m3u8')
306
- playlist = described_class.read(file)
313
+ playlist = described_class.read(
314
+ File.read('spec/fixtures/session_data.m3u8')
315
+ )
307
316
  expect(playlist.session_data)
308
317
  .to all be_a(M3u8::SessionDataItem)
309
318
  expect(playlist.session_data.size).to eq(3)
310
319
  end
311
320
  end
312
321
 
322
+ describe '#session_keys' do
323
+ it 'returns only session key items' do
324
+ playlist = described_class.read(
325
+ File.read('spec/fixtures/master.m3u8')
326
+ )
327
+ expect(playlist.session_keys)
328
+ .to all be_a(M3u8::SessionKeyItem)
329
+ expect(playlist.session_keys.size).to eq(1)
330
+ end
331
+ end
332
+
313
333
  describe '#write' do
314
334
  context 'when playlist is valid' do
315
335
  it 'returns playlist text' do
@@ -7,9 +7,10 @@ describe M3u8::Reader do
7
7
 
8
8
  describe '#read' do
9
9
  it 'parses master playlist' do
10
- file = File.open('spec/fixtures/master.m3u8')
11
10
  reader = M3u8::Reader.new
12
- playlist = reader.read(file)
11
+ playlist = reader.read(
12
+ File.read('spec/fixtures/master.m3u8')
13
+ )
13
14
  expect(playlist.master?).to be true
14
15
  expect(playlist.discontinuity_sequence).to be_nil
15
16
  expect(playlist.independent_segments).to be true
@@ -54,9 +55,10 @@ describe M3u8::Reader do
54
55
  end
55
56
 
56
57
  it 'parses master playlist with I-Frames' do
57
- file = File.open('spec/fixtures/master_iframes.m3u8')
58
58
  reader = M3u8::Reader.new
59
- playlist = reader.read(file)
59
+ playlist = reader.read(
60
+ File.read('spec/fixtures/master_iframes.m3u8')
61
+ )
60
62
  expect(playlist.master?).to be true
61
63
 
62
64
  expect(playlist.items.size).to eq(7)
@@ -69,9 +71,10 @@ describe M3u8::Reader do
69
71
  end
70
72
 
71
73
  it 'parses media playlist' do
72
- file = File.open('spec/fixtures/playlist.m3u8')
73
74
  reader = M3u8::Reader.new
74
- playlist = reader.read(file)
75
+ playlist = reader.read(
76
+ File.read('spec/fixtures/playlist.m3u8')
77
+ )
75
78
  expect(playlist.master?).to be false
76
79
  expect(playlist.version).to eq(4)
77
80
  expect(playlist.sequence).to eq(1)
@@ -94,9 +97,10 @@ describe M3u8::Reader do
94
97
  end
95
98
 
96
99
  it 'parses I-Frame playlist' do
97
- file = File.open('spec/fixtures/iframes.m3u8')
98
100
  reader = M3u8::Reader.new
99
- playlist = reader.read(file)
101
+ playlist = reader.read(
102
+ File.read('spec/fixtures/iframes.m3u8')
103
+ )
100
104
 
101
105
  expect(playlist.iframes_only).to be true
102
106
  expect(playlist.items.size).to eq(3)
@@ -114,9 +118,10 @@ describe M3u8::Reader do
114
118
  end
115
119
 
116
120
  it 'parses segment playlist with comments' do
117
- file = File.open('spec/fixtures/playlist_with_comments.m3u8')
118
121
  reader = M3u8::Reader.new
119
- playlist = reader.read(file)
122
+ playlist = reader.read(
123
+ File.read('spec/fixtures/playlist_with_comments.m3u8')
124
+ )
120
125
  expect(playlist.master?).to be false
121
126
  expect(playlist.version).to eq(4)
122
127
  expect(playlist.sequence).to eq(1)
@@ -136,9 +141,10 @@ describe M3u8::Reader do
136
141
  end
137
142
 
138
143
  it 'parses variant playlist with audio options and groups' do
139
- file = File.open('spec/fixtures/variant_audio.m3u8')
140
144
  reader = M3u8::Reader.new
141
- playlist = reader.read(file)
145
+ playlist = reader.read(
146
+ File.read('spec/fixtures/variant_audio.m3u8')
147
+ )
142
148
 
143
149
  expect(playlist.master?).to be true
144
150
  expect(playlist.items.size).to eq(10)
@@ -157,9 +163,10 @@ describe M3u8::Reader do
157
163
  end
158
164
 
159
165
  it 'parses variant playlist with camera angles' do
160
- file = File.open('spec/fixtures/variant_angles.m3u8')
161
166
  reader = M3u8::Reader.new
162
- playlist = reader.read(file)
167
+ playlist = reader.read(
168
+ File.read('spec/fixtures/variant_angles.m3u8')
169
+ )
163
170
 
164
171
  expect(playlist.master?).to be true
165
172
  expect(playlist.items.size).to eq(11)
@@ -183,22 +190,22 @@ describe M3u8::Reader do
183
190
  end
184
191
 
185
192
  it 'processes multiple reads as separate playlists' do
186
- file = File.open('spec/fixtures/master.m3u8')
187
193
  reader = M3u8::Reader.new
188
- playlist = reader.read(file)
194
+ content = File.read('spec/fixtures/master.m3u8')
195
+ playlist = reader.read(content)
189
196
 
190
197
  expect(playlist.items.size).to eq(8)
191
198
 
192
- file = File.open('spec/fixtures/master.m3u8')
193
- playlist = reader.read(file)
199
+ playlist = reader.read(content)
194
200
 
195
201
  expect(playlist.items.size).to eq(8)
196
202
  end
197
203
 
198
204
  it 'parses playlist with session data' do
199
- file = File.open('spec/fixtures/session_data.m3u8')
200
205
  reader = M3u8::Reader.new
201
- playlist = reader.read(file)
206
+ playlist = reader.read(
207
+ File.read('spec/fixtures/session_data.m3u8')
208
+ )
202
209
 
203
210
  expect(playlist.items.size).to eq(3)
204
211
 
@@ -209,9 +216,10 @@ describe M3u8::Reader do
209
216
  end
210
217
 
211
218
  it 'parses encrypted playlist' do
212
- file = File.open('spec/fixtures/encrypted.m3u8')
213
219
  reader = M3u8::Reader.new
214
- playlist = reader.read(file)
220
+ playlist = reader.read(
221
+ File.read('spec/fixtures/encrypted.m3u8')
222
+ )
215
223
 
216
224
  expect(playlist.items.size).to eq(6)
217
225
 
@@ -222,9 +230,10 @@ describe M3u8::Reader do
222
230
  end
223
231
 
224
232
  it 'parses map (media intialization section) playlists' do
225
- file = File.open('spec/fixtures/map_playlist.m3u8')
226
233
  reader = M3u8::Reader.new
227
- playlist = reader.read(file)
234
+ playlist = reader.read(
235
+ File.read('spec/fixtures/map_playlist.m3u8')
236
+ )
228
237
 
229
238
  expect(playlist.items.size).to eq(1)
230
239
 
@@ -236,9 +245,10 @@ describe M3u8::Reader do
236
245
  end
237
246
 
238
247
  it 'reads segment with timestamp' do
239
- file = File.open('spec/fixtures/timestamp_playlist.m3u8')
240
248
  reader = M3u8::Reader.new
241
- playlist = reader.read(file)
249
+ playlist = reader.read(
250
+ File.read('spec/fixtures/timestamp_playlist.m3u8')
251
+ )
242
252
  expect(playlist.items.count).to eq(6)
243
253
 
244
254
  item_date_time = playlist.items.first.program_date_time
@@ -247,9 +257,10 @@ describe M3u8::Reader do
247
257
  end
248
258
 
249
259
  it 'parses playlist with daterange' do
250
- file = File.open('spec/fixtures/date_range_scte35.m3u8')
251
260
  reader = M3u8::Reader.new
252
- playlist = reader.read(file)
261
+ playlist = reader.read(
262
+ File.read('spec/fixtures/date_range_scte35.m3u8')
263
+ )
253
264
  expect(playlist.items.count).to eq(5)
254
265
 
255
266
  item = playlist.items[0]
@@ -260,9 +271,10 @@ describe M3u8::Reader do
260
271
  end
261
272
 
262
273
  it 'parses master playlist with v13 attributes' do
263
- file = File.open('spec/fixtures/master_v13.m3u8')
264
274
  reader = M3u8::Reader.new
265
- playlist = reader.read(file)
275
+ playlist = reader.read(
276
+ File.read('spec/fixtures/master_v13.m3u8')
277
+ )
266
278
  expect(playlist.master?).to be true
267
279
  expect(playlist.version).to eq(13)
268
280
 
@@ -288,9 +300,10 @@ describe M3u8::Reader do
288
300
  end
289
301
 
290
302
  it 'parses playlist with content steering and defines' do
291
- file = File.open('spec/fixtures/content_steering.m3u8')
292
303
  reader = M3u8::Reader.new
293
- playlist = reader.read(file)
304
+ playlist = reader.read(
305
+ File.read('spec/fixtures/content_steering.m3u8')
306
+ )
294
307
  expect(playlist.master?).to be true
295
308
  expect(playlist.items.size).to eq(5)
296
309
 
@@ -310,9 +323,10 @@ describe M3u8::Reader do
310
323
  end
311
324
 
312
325
  it 'parses LL-HLS playlist' do
313
- file = File.open('spec/fixtures/ll_hls_playlist.m3u8')
314
326
  reader = M3u8::Reader.new
315
- playlist = reader.read(file)
327
+ playlist = reader.read(
328
+ File.read('spec/fixtures/ll_hls_playlist.m3u8')
329
+ )
316
330
  expect(playlist.master?).to be false
317
331
  expect(playlist.live?).to be true
318
332
  expect(playlist.version).to eq(9)
@@ -361,9 +375,10 @@ describe M3u8::Reader do
361
375
  end
362
376
 
363
377
  it 'parses playlist with gap and bitrate tags' do
364
- file = File.open('spec/fixtures/gap_playlist.m3u8')
365
378
  reader = M3u8::Reader.new
366
- playlist = reader.read(file)
379
+ playlist = reader.read(
380
+ File.read('spec/fixtures/gap_playlist.m3u8')
381
+ )
367
382
  expect(playlist.master?).to be false
368
383
  expect(playlist.items.size).to eq(6)
369
384
 
@@ -383,8 +398,9 @@ describe M3u8::Reader do
383
398
  end
384
399
 
385
400
  it 'parses event playlist with byterange and map change' do
386
- file = File.open('spec/fixtures/event_playlist.m3u8')
387
- playlist = reader.read(file)
401
+ playlist = reader.read(
402
+ File.read('spec/fixtures/event_playlist.m3u8')
403
+ )
388
404
  expect(playlist.master?).to be false
389
405
  expect(playlist.live?).to be false
390
406
  expect(playlist.type).to eq('EVENT')
@@ -417,8 +433,9 @@ describe M3u8::Reader do
417
433
  end
418
434
 
419
435
  it 'parses daterange playlist' do
420
- file = File.open('spec/fixtures/daterange_playlist.m3u8')
421
- playlist = reader.read(file)
436
+ playlist = reader.read(
437
+ File.read('spec/fixtures/daterange_playlist.m3u8')
438
+ )
422
439
  expect(playlist.master?).to be false
423
440
  expect(playlist.items.size).to eq(6)
424
441
 
@@ -445,8 +462,9 @@ describe M3u8::Reader do
445
462
  end
446
463
 
447
464
  it 'parses full master playlist' do
448
- file = File.open('spec/fixtures/master_full.m3u8')
449
- playlist = reader.read(file)
465
+ playlist = reader.read(
466
+ File.read('spec/fixtures/master_full.m3u8')
467
+ )
450
468
  expect(playlist.master?).to be true
451
469
  expect(playlist.version).to eq(13)
452
470
  expect(playlist.independent_segments).to be true
@@ -489,8 +507,9 @@ describe M3u8::Reader do
489
507
  end
490
508
 
491
509
  it 'parses encrypted playlist with discontinuities' do
492
- file = File.open('spec/fixtures/encrypted_discontinuity.m3u8')
493
- playlist = reader.read(file)
510
+ playlist = reader.read(
511
+ File.read('spec/fixtures/encrypted_discontinuity.m3u8')
512
+ )
494
513
  expect(playlist.master?).to be false
495
514
  expect(playlist.live?).to be false
496
515
  expect(playlist.items.size).to eq(8)
@@ -515,8 +534,9 @@ describe M3u8::Reader do
515
534
  end
516
535
 
517
536
  it 'parses advanced LL-HLS playlist' do
518
- file = File.open('spec/fixtures/ll_hls_advanced.m3u8')
519
- playlist = reader.read(file)
537
+ playlist = reader.read(
538
+ File.read('spec/fixtures/ll_hls_advanced.m3u8')
539
+ )
520
540
  expect(playlist.master?).to be false
521
541
  expect(playlist.live?).to be true
522
542
  expect(playlist.version).to eq(9)
@@ -76,9 +76,7 @@ describe 'Round-trip serialization' do
76
76
  second = parse(first.to_s)
77
77
 
78
78
  expect(second.items.size).to eq(first.items.size)
79
- defines = second.items.select do |i|
80
- i.is_a?(M3u8::DefineItem)
81
- end
79
+ defines = second.items.grep(M3u8::DefineItem)
82
80
  expect(defines.size).to eq(2)
83
81
 
84
82
  steering = second.items.find do |i|
@@ -106,9 +104,7 @@ describe 'Round-trip serialization' do
106
104
  second = parse(first.to_s)
107
105
 
108
106
  expect(second.items.size).to eq(first.items.size)
109
- media = second.items.select do |i|
110
- i.is_a?(M3u8::MediaItem)
111
- end
107
+ media = second.items.grep(M3u8::MediaItem)
112
108
  expect(media.size).to eq(6)
113
109
  expect(media.first.group_id).to eq('audio-lo')
114
110
  end
@@ -119,9 +115,7 @@ describe 'Round-trip serialization' do
119
115
  second = parse(first.to_s)
120
116
 
121
117
  expect(second.items.size).to eq(first.items.size)
122
- media = second.items.select do |i|
123
- i.is_a?(M3u8::MediaItem)
124
- end
118
+ media = second.items.grep(M3u8::MediaItem)
125
119
  expect(media.size).to eq(9)
126
120
  types = media.map(&:type).uniq.sort
127
121
  expect(types).to eq(%w[AUDIO CLOSED-CAPTIONS
metadata CHANGED
@@ -1,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: m3u8
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Seth Deckard
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
11
  date: 2026-02-28 00:00:00.000000000 Z
@@ -125,7 +125,8 @@ dependencies:
125
125
  description: Generate and parse m3u8 playlists for HTTP Live Streaming (HLS).
126
126
  email:
127
127
  - seth@deckard.me
128
- executables: []
128
+ executables:
129
+ - m3u8
129
130
  extensions: []
130
131
  extra_rdoc_files: []
131
132
  files:
@@ -134,18 +135,20 @@ files:
134
135
  - ".hound.yml"
135
136
  - ".rspec"
136
137
  - ".rubocop.yml"
137
- - AGENTS.md
138
138
  - CHANGELOG.md
139
- - CLAUDE.md
140
139
  - Gemfile
141
140
  - Guardfile
142
141
  - LICENSE.txt
143
142
  - README.md
144
143
  - Rakefile
144
+ - bin/m3u8
145
145
  - lib/m3u8.rb
146
146
  - lib/m3u8/bitrate_item.rb
147
147
  - lib/m3u8/builder.rb
148
148
  - lib/m3u8/byte_range.rb
149
+ - lib/m3u8/cli.rb
150
+ - lib/m3u8/cli/inspect_command.rb
151
+ - lib/m3u8/cli/validate_command.rb
149
152
  - lib/m3u8/content_steering_item.rb
150
153
  - lib/m3u8/date_range_item.rb
151
154
  - lib/m3u8/define_item.rb
@@ -197,6 +200,9 @@ files:
197
200
  - spec/lib/m3u8/bitrate_item_spec.rb
198
201
  - spec/lib/m3u8/builder_spec.rb
199
202
  - spec/lib/m3u8/byte_range_spec.rb
203
+ - spec/lib/m3u8/cli/inspect_command_spec.rb
204
+ - spec/lib/m3u8/cli/validate_command_spec.rb
205
+ - spec/lib/m3u8/cli_spec.rb
200
206
  - spec/lib/m3u8/content_steering_item_spec.rb
201
207
  - spec/lib/m3u8/date_range_item_spec.rb
202
208
  - spec/lib/m3u8/define_item_spec.rb
@@ -227,7 +233,7 @@ homepage: https://github.com/sethdeckard/m3u8
227
233
  licenses:
228
234
  - MIT
229
235
  metadata: {}
230
- post_install_message:
236
+ post_install_message:
231
237
  rdoc_options: []
232
238
  require_paths:
233
239
  - lib
@@ -242,8 +248,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
242
248
  - !ruby/object:Gem::Version
243
249
  version: '0'
244
250
  requirements: []
245
- rubygems_version: 3.0.3.1
246
- signing_key:
251
+ rubygems_version: 3.5.22
252
+ signing_key:
247
253
  specification_version: 4
248
254
  summary: Generate and parse m3u8 playlists for HTTP Live Streaming (HLS).
249
255
  test_files: []
data/AGENTS.md DELETED
@@ -1,27 +0,0 @@
1
- # AGENTS.md
2
-
3
- ## Development Workflow
4
-
5
- - Git workflow: GitHub flow
6
- - Small (but logical) commits that can each be deployed independently
7
- - Each commit must not break CI
8
- - Prefer incremental changes over large feature branches
9
-
10
- ### Commit Messages
11
-
12
- **Subject:** Max 50 chars, capitalized, no period, imperative mood ("Add" not "Added")
13
-
14
- **Body:** Wrap at 72 chars, explain what/why not how, blank line after subject
15
-
16
- **Leading verbs:** Add, Remove, Fix, Upgrade, Refactor, Reformat, Document, Reword
17
-
18
- ## Development Standards
19
-
20
- - README updated with API changes
21
- - **Tests must cover all behavior** - check with `coverage/index.html` after running specs
22
- - RuboCop enforces 80-char line limit and other style
23
-
24
- ## Deployment
25
-
26
- - Kicking off PR: `ghprcw`
27
- - Never deploy anything
data/CLAUDE.md DELETED
@@ -1 +0,0 @@
1
- ./AGENTS.md