m3u8 1.4.0 → 1.6.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: 3a4ef59c0103a519f686f38d7eb24e99286f698bfbee43d7e8ad48827dfd00e5
4
- data.tar.gz: b776355294c670836ecc6598ad169d71eedc40e017a686c28042828bf724e805
3
+ metadata.gz: 1fb7826061b077f6e56712067f673393591d628e4e35a895af1910ff7fae859d
4
+ data.tar.gz: 8e0298d7fb9806e4fa2d3bc66b522a6f7bf7d0bbad1a57fcefeafeac7255a1f4
5
5
  SHA512:
6
- metadata.gz: e2f761adbf62244f9f175fc28f7f00f0d77d78acb5af19906f70fff656777e97bbbe787454ce47accd0e72f4e702baeda20dde763a72ac87174cb9ccd24dca15
7
- data.tar.gz: bf19a4de08cc5216b939c77eedc147e93e134bc284a90464f138f3a31f0d679d88e5bd6e306db63dbf0e26ce7a40fba618bb67d41c15443899a2824b6fd4776c
6
+ metadata.gz: 20c066c9f250d0d7948c3211f958bf553d8385d07f8715274b2738d3c497cf8cf450790489f80ccca88a1e09a67fc43411f055efcc16bf6f9fa07efed43ed407
7
+ data.tar.gz: 515ba07430d27872130625dc1d5eac52ebd68a76c20306ff4443b9829f74b3d3a7ceee8d63f118a7e832c6f48a7742e6157eb5bff05e26c198f262566f443c78
data/CHANGELOG.md CHANGED
@@ -1,3 +1,23 @@
1
+ **1.6.0**
2
+
3
+ * Added SCTE-35 parsing with `M3u8::Scte35` for `splice_info_section`
4
+ payloads, including `splice_null` (0x00), `splice_insert` (0x05),
5
+ `time_signal` (0x06), and descriptor loop parsing.
6
+ * Added `DateRangeItem` SCTE-35 convenience methods:
7
+ `scte35_cmd_info`, `scte35_out_info`, and `scte35_in_info`.
8
+ * Added parsing support for SCTE-35 segmentation descriptors
9
+ (`segmentation_descriptor`, tag 0x02 with `CUEI` identifier).
10
+ * Added `M3u8::Scte35::ParseError` for malformed SCTE-35 payloads.
11
+ * Documented SCTE-35 usage and parsed fields in README.
12
+
13
+ ***
14
+
15
+ **1.5.0**
16
+
17
+ * Added `Playlist#freeze` for deep-freezing playlists, items, nested objects, and playlist-level objects. `Playlist.build` and `Playlist.read` now return frozen playlists. `Playlist.new` remains mutable until `freeze` is called explicitly.
18
+
19
+ ***
20
+
1
21
  **1.4.0**
2
22
 
3
23
  * Added `Playlist#errors` method returning an array of validation error messages. `Playlist#valid?` now delegates to `errors.empty?`. Validates mixed item types, target duration, segment items, playlist items, media items, encryption keys, session keys, session data, and LL-HLS part items.
data/README.md CHANGED
@@ -344,6 +344,83 @@ options = { width: 1920, height: 1080, codecs: 'avc1.66.30,mp4a.40.2',
344
344
  item = M3u8::PlaylistItem.new(options)
345
345
  ```
346
346
 
347
+ ## Frozen playlists
348
+
349
+ Playlists returned by `Playlist.build` and `Playlist.read` are frozen (deeply immutable). Items, nested objects, and the items array are all frozen, preventing accidental mutation after construction:
350
+
351
+ ```ruby
352
+ playlist = M3u8::Playlist.read(File.open('master.m3u8'))
353
+ playlist.frozen? # => true
354
+ playlist.items.frozen? # => true
355
+ playlist.items.first.frozen? # => true
356
+ ```
357
+
358
+ Playlists created with `Playlist.new` remain mutable. Call `freeze` explicitly when ready:
359
+
360
+ ```ruby
361
+ playlist = M3u8::Playlist.new
362
+ playlist.items << M3u8::SegmentItem.new(duration: 10.0, segment: 'test.ts')
363
+ playlist.freeze
364
+ ```
365
+
366
+ Frozen playlists still support `to_s` and `write` for output.
367
+
368
+ ## SCTE-35 parsing
369
+
370
+ `DateRangeItem` stores SCTE-35 values (`scte35_cmd`, `scte35_out`, `scte35_in`) as raw hex strings. Convenience methods parse them into structured objects:
371
+
372
+ ```ruby
373
+ playlist = M3u8::Playlist.read(file)
374
+ date_range = playlist.date_ranges.first
375
+
376
+ info = date_range.scte35_out_info
377
+ info.table_id # => 252 (0xFC)
378
+ info.pts_adjustment # => 0
379
+ info.tier # => 4095
380
+ info.splice_command_type # => 5
381
+
382
+ cmd = info.splice_command # => Scte35SpliceInsert
383
+ cmd.splice_event_id # => 1
384
+ cmd.out_of_network_indicator # => true
385
+ cmd.pts_time # => 90000
386
+ cmd.break_duration # => 2700000
387
+ cmd.break_auto_return # => true
388
+ ```
389
+
390
+ Parse any SCTE-35 hex string directly:
391
+
392
+ ```ruby
393
+ info = M3u8::Scte35.parse('0xFC301100...')
394
+ info.to_s # => original hex string
395
+ ```
396
+
397
+ ### Command types
398
+
399
+ | Type | Class | Key attributes |
400
+ |------|-------|----------------|
401
+ | 0x00 | `Scte35SpliceNull` | *(none)* |
402
+ | 0x05 | `Scte35SpliceInsert` | `splice_event_id`, `out_of_network_indicator`, `pts_time`, `break_duration`, `break_auto_return`, `unique_program_id`, `avail_num`, `avails_expected` |
403
+ | 0x06 | `Scte35TimeSignal` | `pts_time` |
404
+
405
+ Unknown command types store raw bytes in `splice_command`.
406
+
407
+ ### Descriptors
408
+
409
+ Segmentation descriptors (tag 0x02, identifier `CUEI`) are parsed as `Scte35SegmentationDescriptor`:
410
+
411
+ ```ruby
412
+ desc = info.descriptors.first
413
+ desc.segmentation_event_id # => 1
414
+ desc.segmentation_type_id # => 0x30
415
+ desc.segmentation_duration # => 2700000
416
+ desc.segmentation_upid_type # => 9
417
+ desc.segmentation_upid # => "SIGNAL123"
418
+ desc.segment_num # => 0
419
+ desc.segments_expected # => 0
420
+ ```
421
+
422
+ Unknown descriptor tags store raw bytes.
423
+
347
424
  ## Validation
348
425
 
349
426
  Check whether a playlist is valid and inspect specific errors:
@@ -34,6 +34,18 @@ module M3u8
34
34
  "#EXT-X-DATERANGE:#{formatted_attributes}"
35
35
  end
36
36
 
37
+ def scte35_cmd_info
38
+ Scte35.parse(scte35_cmd) unless scte35_cmd.nil?
39
+ end
40
+
41
+ def scte35_out_info
42
+ Scte35.parse(scte35_out) unless scte35_out.nil?
43
+ end
44
+
45
+ def scte35_in_info
46
+ Scte35.parse(scte35_in) unless scte35_in.nil?
47
+ end
48
+
37
49
  private
38
50
 
39
51
  def formatted_attributes
data/lib/m3u8/playlist.rb CHANGED
@@ -22,7 +22,7 @@ module M3u8
22
22
  else
23
23
  builder.instance_eval(&block)
24
24
  end
25
- playlist
25
+ playlist.freeze
26
26
  end
27
27
 
28
28
  def self.codecs(options = {})
@@ -53,6 +53,14 @@ module M3u8
53
53
  playlist_size.positive?
54
54
  end
55
55
 
56
+ def freeze
57
+ items.each { |item| freeze_item(item) }
58
+ items.freeze
59
+ part_inf&.freeze
60
+ server_control&.freeze
61
+ super
62
+ end
63
+
56
64
  def to_s
57
65
  output = StringIO.open
58
66
  write(output)
@@ -119,6 +127,13 @@ module M3u8
119
127
 
120
128
  private
121
129
 
130
+ def freeze_item(item)
131
+ item.byterange&.freeze if item.respond_to?(:byterange)
132
+ item.program_date_time&.freeze if item.respond_to?(:program_date_time)
133
+ item.client_attributes&.freeze if item.respond_to?(:client_attributes)
134
+ item.freeze
135
+ end
136
+
122
137
  def assign_options(options)
123
138
  options = defaults.merge(options)
124
139
 
data/lib/m3u8/reader.rb CHANGED
@@ -25,7 +25,7 @@ module M3u8
25
25
  parse_line(line)
26
26
  end
27
27
  playlist.live = !@has_endlist unless master
28
- playlist
28
+ playlist.freeze
29
29
  end
30
30
 
31
31
  private
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module M3u8
4
+ # Parses SCTE-35 splice_info_section binary payloads from hex strings
5
+ class Scte35
6
+ class ParseError < StandardError; end
7
+
8
+ attr_reader :table_id, :pts_adjustment, :tier,
9
+ :splice_command_type, :splice_command, :descriptors
10
+
11
+ def initialize(options = {})
12
+ options.each do |key, value|
13
+ instance_variable_set("@#{key}", value)
14
+ end
15
+ end
16
+
17
+ def self.parse(hex_string)
18
+ raw = hex_string.sub(/\A0x/i, '')
19
+ data = [raw].pack('H*')
20
+ reader = Scte35BitReader.new(data)
21
+
22
+ header = parse_header(reader)
23
+ command = parse_command(reader, header)
24
+ descriptors = parse_descriptors(reader, header)
25
+
26
+ args = header.merge(splice_command: command,
27
+ descriptors: descriptors, raw: hex_string)
28
+ new(**args)
29
+ rescue NoMethodError, ArgumentError => e
30
+ raise ParseError, "invalid SCTE-35 data: #{e.message}"
31
+ end
32
+
33
+ def to_s
34
+ @raw
35
+ end
36
+
37
+ def self.parse_header(reader)
38
+ table_id = reader.read_bits(8)
39
+ reader.skip_bits(4) # section_syntax_indicator, private, reserved
40
+ section_length = reader.read_bits(12)
41
+ reader.read_bits(8) # protocol_version
42
+ reader.read_flag # encrypted_packet
43
+ reader.read_bits(6) # encryption_algorithm
44
+ pts_adjustment = reader.read_bits(33)
45
+ reader.read_bits(8) # cw_index
46
+ tier = reader.read_bits(12)
47
+ splice_command_length = reader.read_bits(12)
48
+ splice_command_type = reader.read_bits(8)
49
+
50
+ { table_id: table_id, pts_adjustment: pts_adjustment,
51
+ tier: tier, splice_command_type: splice_command_type,
52
+ splice_command_length: splice_command_length,
53
+ section_length: section_length }
54
+ end
55
+
56
+ def self.parse_command(reader, header)
57
+ cmd_length = command_data_length(reader, header)
58
+
59
+ case header[:splice_command_type]
60
+ when 0x00 then Scte35SpliceNull.new
61
+ when 0x05 then Scte35SpliceInsert.parse_from(reader, cmd_length)
62
+ when 0x06 then Scte35TimeSignal.parse_from(reader, cmd_length)
63
+ else reader.read_bytes(cmd_length)
64
+ end
65
+ end
66
+
67
+ def self.parse_splice_time(reader)
68
+ unless reader.read_flag # time_specified
69
+ reader.skip_bits(7) # reserved
70
+ return nil
71
+ end
72
+
73
+ reader.skip_bits(6) # reserved
74
+ reader.read_bits(33)
75
+ end
76
+
77
+ def self.parse_descriptors(reader, header)
78
+ # For unknown commands with 0xFFF, command consumed all
79
+ # remaining bytes (per spec), so no descriptors to parse
80
+ return [] if unknown_command_with_unspecified_length?(header)
81
+
82
+ desc_loop_length = reader.read_bits(16)
83
+ parse_descriptor_loop(reader, desc_loop_length)
84
+ end
85
+
86
+ def self.unknown_command_with_unspecified_length?(header)
87
+ header[:splice_command_length] == 0xFFF &&
88
+ ![0x00, 0x05, 0x06].include?(header[:splice_command_type])
89
+ end
90
+
91
+ def self.parse_descriptor_loop(reader, remaining)
92
+ descriptors = []
93
+ while remaining.positive?
94
+ tag = reader.read_bits(8)
95
+ length = reader.read_bits(8)
96
+ remaining -= 2 + length
97
+ descriptors << parse_single_descriptor(reader, tag, length)
98
+ end
99
+ descriptors
100
+ end
101
+
102
+ def self.parse_single_descriptor(reader, tag, length)
103
+ identifier = reader.read_bits(32)
104
+ if tag == Scte35SegmentationDescriptor::DESCRIPTOR_TAG &&
105
+ identifier == Scte35SegmentationDescriptor::CUEI_IDENTIFIER
106
+ Scte35SegmentationDescriptor.parse_from(reader, length)
107
+ else
108
+ reader.read_bytes(length - 4)
109
+ end
110
+ end
111
+
112
+ def self.command_data_length(reader, header)
113
+ return header[:splice_command_length] unless header[:splice_command_length] == 0xFFF
114
+
115
+ if unknown_command_with_unspecified_length?(header)
116
+ # Unknown command: consume everything up to CRC
117
+ reader.bytes_remaining - 4
118
+ else
119
+ # Known command types parse their own fields; length unused
120
+ 0
121
+ end
122
+ end
123
+
124
+ private_class_method :parse_header, :parse_command,
125
+ :parse_descriptors, :command_data_length,
126
+ :parse_descriptor_loop, :parse_single_descriptor,
127
+ :unknown_command_with_unspecified_length?
128
+ end
129
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module M3u8
4
+ # Reads sub-byte bit fields from binary data for SCTE-35 parsing
5
+ class Scte35BitReader
6
+ def initialize(data)
7
+ @data = data.b
8
+ @byte_pos = 0
9
+ @bit_pos = 0
10
+ end
11
+
12
+ def read_bits(count)
13
+ value = 0
14
+ count.times do
15
+ value = (value << 1) | current_bit
16
+ advance_bit
17
+ end
18
+ value
19
+ end
20
+
21
+ def read_flag
22
+ read_bits(1) == 1
23
+ end
24
+
25
+ def read_bytes(count)
26
+ @data[@byte_pos, count].tap { @byte_pos += count }
27
+ end
28
+
29
+ def skip_bits(count)
30
+ count.times { advance_bit }
31
+ end
32
+
33
+ def bytes_remaining
34
+ @data.length - @byte_pos - (@bit_pos.positive? ? 1 : 0)
35
+ end
36
+
37
+ private
38
+
39
+ def current_bit
40
+ (@data.getbyte(@byte_pos) >> (7 - @bit_pos)) & 1
41
+ end
42
+
43
+ def advance_bit
44
+ @bit_pos += 1
45
+ return unless @bit_pos >= 8
46
+
47
+ @bit_pos = 0
48
+ @byte_pos += 1
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module M3u8
4
+ # Represents a segmentation_descriptor (tag 0x02) in SCTE-35
5
+ class Scte35SegmentationDescriptor
6
+ CUEI_IDENTIFIER = 0x43554549
7
+ DESCRIPTOR_TAG = 0x02
8
+
9
+ attr_reader :segmentation_event_id, :segmentation_event_cancel_indicator,
10
+ :segmentation_type_id, :segmentation_duration,
11
+ :segmentation_upid_type, :segmentation_upid,
12
+ :segment_num, :segments_expected
13
+
14
+ def initialize(options = {})
15
+ options.each do |key, value|
16
+ instance_variable_set("@#{key}", value)
17
+ end
18
+ end
19
+
20
+ def self.parse_from(reader, _length)
21
+ attrs = { segmentation_event_id: reader.read_bits(32) }
22
+ attrs[:segmentation_event_cancel_indicator] = reader.read_flag
23
+ reader.skip_bits(7) # reserved
24
+ return new(**attrs) if attrs[:segmentation_event_cancel_indicator]
25
+
26
+ parse_segmentation_detail(reader, attrs)
27
+ end
28
+
29
+ def self.parse_segmentation_detail(reader, attrs)
30
+ reader.read_flag # program_segmentation_flag
31
+ duration_flag = reader.read_flag
32
+ reader.skip_bits(6) # delivery_not_restricted + reserved/flags
33
+
34
+ attrs[:segmentation_duration] = reader.read_bits(40) if duration_flag
35
+ parse_upid(reader, attrs)
36
+ attrs[:segmentation_type_id] = reader.read_bits(8)
37
+ attrs[:segment_num] = reader.read_bits(8)
38
+ attrs[:segments_expected] = reader.read_bits(8)
39
+ new(**attrs)
40
+ end
41
+
42
+ def self.parse_upid(reader, attrs)
43
+ attrs[:segmentation_upid_type] = reader.read_bits(8)
44
+ upid_length = reader.read_bits(8)
45
+ return if upid_length.zero?
46
+
47
+ attrs[:segmentation_upid] = reader.read_bytes(upid_length).force_encoding('UTF-8')
48
+ end
49
+
50
+ private_class_method :parse_segmentation_detail, :parse_upid
51
+ end
52
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module M3u8
4
+ # Represents a splice_insert SCTE-35 command (type 0x05)
5
+ class Scte35SpliceInsert
6
+ attr_reader :splice_event_id, :splice_event_cancel_indicator,
7
+ :out_of_network_indicator, :pts_time,
8
+ :break_duration, :break_auto_return,
9
+ :unique_program_id, :avail_num, :avails_expected
10
+
11
+ def initialize(options = {})
12
+ options.each do |key, value|
13
+ instance_variable_set("@#{key}", value)
14
+ end
15
+ end
16
+
17
+ def self.parse_from(reader, _length)
18
+ attrs = { splice_event_id: reader.read_bits(32) }
19
+ attrs[:splice_event_cancel_indicator] = reader.read_flag
20
+ reader.skip_bits(7) # reserved
21
+ return new(**attrs) if attrs[:splice_event_cancel_indicator]
22
+
23
+ parse_splice_detail(reader, attrs)
24
+ end
25
+
26
+ def self.parse_splice_detail(reader, attrs)
27
+ attrs[:out_of_network_indicator] = reader.read_flag
28
+ program_splice = reader.read_flag
29
+ duration_flag = reader.read_flag
30
+ immediate = reader.read_flag
31
+ reader.skip_bits(4) # reserved
32
+
33
+ if program_splice
34
+ attrs[:pts_time] = Scte35.parse_splice_time(reader) unless immediate
35
+ else
36
+ parse_components(reader, immediate)
37
+ end
38
+ parse_break_duration(reader, attrs) if duration_flag
39
+ attrs[:unique_program_id] = reader.read_bits(16)
40
+ attrs[:avail_num] = reader.read_bits(8)
41
+ attrs[:avails_expected] = reader.read_bits(8)
42
+ new(**attrs)
43
+ end
44
+
45
+ def self.parse_components(reader, immediate)
46
+ component_count = reader.read_bits(8)
47
+ component_count.times do
48
+ reader.read_bits(8) # component_tag
49
+ Scte35.parse_splice_time(reader) unless immediate
50
+ end
51
+ end
52
+
53
+ def self.parse_break_duration(reader, attrs)
54
+ attrs[:break_auto_return] = reader.read_flag
55
+ reader.skip_bits(6) # reserved
56
+ attrs[:break_duration] = reader.read_bits(33)
57
+ end
58
+
59
+ private_class_method :parse_splice_detail, :parse_break_duration,
60
+ :parse_components
61
+ end
62
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module M3u8
4
+ # Represents a splice_null SCTE-35 command (type 0x00).
5
+ # Contains no data fields.
6
+ class Scte35SpliceNull # rubocop:disable Lint/EmptyClass
7
+ end
8
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module M3u8
4
+ # Represents a time_signal SCTE-35 command (type 0x06)
5
+ class Scte35TimeSignal
6
+ attr_reader :pts_time
7
+
8
+ def initialize(options = {})
9
+ options.each do |key, value|
10
+ instance_variable_set("@#{key}", value)
11
+ end
12
+ end
13
+
14
+ def self.parse_from(reader, _length)
15
+ pts_time = Scte35.parse_splice_time(reader)
16
+ new(pts_time: pts_time)
17
+ end
18
+ end
19
+ end
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.4.0'
5
+ VERSION = '1.6.0'
6
6
  end
@@ -191,6 +191,20 @@ describe M3u8::Builder do
191
191
  expect(playlist.target).to eq(12)
192
192
  end
193
193
 
194
+ it 'returns a frozen playlist' do
195
+ playlist = M3u8::Playlist.build do
196
+ segment duration: 10.0, segment: 'test.ts'
197
+ end
198
+ expect(playlist).to be_frozen
199
+ end
200
+
201
+ it 'returns a frozen playlist with yielded form' do
202
+ playlist = M3u8::Playlist.build do |b|
203
+ b.segment duration: 10.0, segment: 'test.ts'
204
+ end
205
+ expect(playlist).to be_frozen
206
+ end
207
+
194
208
  it 'supports yielded builder form' do
195
209
  files = %w[seg1.ts seg2.ts]
196
210
  playlist = M3u8::Playlist.build(version: 4) do |b|
@@ -122,4 +122,51 @@ describe M3u8::DateRangeItem do
122
122
  expect(item.to_s).to eq(expected)
123
123
  end
124
124
  end
125
+
126
+ describe '#scte35_out_info' do
127
+ it 'should return parsed Scte35 when scte35_out is set' do
128
+ hex = '0xFC301100000000000000FFF000000000DEADBEEF'
129
+ item = described_class.new(scte35_out: hex)
130
+ result = item.scte35_out_info
131
+
132
+ expect(result).to be_a(M3u8::Scte35)
133
+ expect(result.table_id).to eq(0xFC)
134
+ expect(result.to_s).to eq(hex)
135
+ end
136
+
137
+ it 'should return nil when scte35_out is nil' do
138
+ item = described_class.new
139
+ expect(item.scte35_out_info).to be_nil
140
+ end
141
+ end
142
+
143
+ describe '#scte35_in_info' do
144
+ it 'should return parsed Scte35 when scte35_in is set' do
145
+ hex = '0xFC301100000000000000FFF000000000DEADBEEF'
146
+ item = described_class.new(scte35_in: hex)
147
+ result = item.scte35_in_info
148
+
149
+ expect(result).to be_a(M3u8::Scte35)
150
+ end
151
+
152
+ it 'should return nil when scte35_in is nil' do
153
+ item = described_class.new
154
+ expect(item.scte35_in_info).to be_nil
155
+ end
156
+ end
157
+
158
+ describe '#scte35_cmd_info' do
159
+ it 'should return parsed Scte35 when scte35_cmd is set' do
160
+ hex = '0xFC301100000000000000FFF000000000DEADBEEF'
161
+ item = described_class.new(scte35_cmd: hex)
162
+ result = item.scte35_cmd_info
163
+
164
+ expect(result).to be_a(M3u8::Scte35)
165
+ end
166
+
167
+ it 'should return nil when scte35_cmd is nil' do
168
+ item = described_class.new
169
+ expect(item.scte35_cmd_info).to be_nil
170
+ end
171
+ end
125
172
  end