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,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe 'Round-trip serialization' do
6
+ def read_fixture(name)
7
+ File.read("spec/fixtures/#{name}")
8
+ end
9
+
10
+ def parse(text)
11
+ M3u8::Playlist.read(text)
12
+ end
13
+
14
+ context 'exact string round-trip (canonical fixtures)' do
15
+ %w[
16
+ event_playlist.m3u8
17
+ daterange_playlist.m3u8
18
+ master_full.m3u8
19
+ encrypted_discontinuity.m3u8
20
+ ll_hls_advanced.m3u8
21
+ ].each do |fixture|
22
+ it "round-trips #{fixture}" do
23
+ text = read_fixture(fixture)
24
+ expect(parse(text).to_s).to eq(text)
25
+ end
26
+ end
27
+ end
28
+
29
+ context 'semantic round-trip (non-canonical fixtures)' do
30
+ it 'round-trips master.m3u8' do
31
+ text = read_fixture('master.m3u8')
32
+ first = parse(text)
33
+ second = parse(first.to_s)
34
+
35
+ expect(second.master?).to be true
36
+ expect(second.items.size).to eq(first.items.size)
37
+ expect(second.independent_segments).to eq(true)
38
+
39
+ item = second.items[2]
40
+ expect(item).to be_a(M3u8::PlaylistItem)
41
+ expect(item.bandwidth).to eq(5_042_000)
42
+ expect(item.codecs).to eq('avc1.640028,mp4a.40.2')
43
+ end
44
+
45
+ it 'round-trips playlist.m3u8' do
46
+ text = read_fixture('playlist.m3u8')
47
+ first = parse(text)
48
+ second = parse(first.to_s)
49
+
50
+ expect(second.master?).to be false
51
+ expect(second.items.size).to eq(first.items.size)
52
+ expect(second.version).to eq(4)
53
+ expect(second.sequence).to eq(1)
54
+ expect(second.type).to eq('VOD')
55
+ end
56
+
57
+ it 'round-trips master_v13.m3u8' do
58
+ text = read_fixture('master_v13.m3u8')
59
+ first = parse(text)
60
+ second = parse(first.to_s)
61
+
62
+ expect(second.version).to eq(13)
63
+ item = second.items[1]
64
+ expect(item).to be_a(M3u8::PlaylistItem)
65
+ expect(item.stable_variant_id).to eq('hd-1080')
66
+ expect(item.video_range).to eq('SDR')
67
+ expect(item.pathway_id).to eq('CDN-A')
68
+ expect(item.score).to eq(12.5)
69
+ expect(item.supplemental_codecs)
70
+ .to eq('dvh1.05.06/db4g')
71
+ end
72
+
73
+ it 'round-trips content_steering.m3u8' do
74
+ text = read_fixture('content_steering.m3u8')
75
+ first = parse(text)
76
+ second = parse(first.to_s)
77
+
78
+ expect(second.items.size).to eq(first.items.size)
79
+ defines = second.items.grep(M3u8::DefineItem)
80
+ expect(defines.size).to eq(2)
81
+
82
+ steering = second.items.find do |i|
83
+ i.is_a?(M3u8::ContentSteeringItem)
84
+ end
85
+ expect(steering.server_uri)
86
+ .to eq('https://example.com/steering')
87
+ expect(steering.pathway_id).to eq('CDN-A')
88
+ end
89
+
90
+ it 'round-trips ll_hls_playlist.m3u8' do
91
+ text = read_fixture('ll_hls_playlist.m3u8')
92
+ first = parse(text)
93
+ second = parse(first.to_s)
94
+
95
+ expect(second.server_control.can_skip_until)
96
+ .to eq(24.0)
97
+ expect(second.part_inf.part_target).to eq(0.5)
98
+ expect(second.items.size).to eq(first.items.size)
99
+ end
100
+
101
+ it 'round-trips variant_audio.m3u8' do
102
+ text = read_fixture('variant_audio.m3u8')
103
+ first = parse(text)
104
+ second = parse(first.to_s)
105
+
106
+ expect(second.items.size).to eq(first.items.size)
107
+ media = second.items.grep(M3u8::MediaItem)
108
+ expect(media.size).to eq(6)
109
+ expect(media.first.group_id).to eq('audio-lo')
110
+ end
111
+
112
+ it 'round-trips variant_angles.m3u8' do
113
+ text = read_fixture('variant_angles.m3u8')
114
+ first = parse(text)
115
+ second = parse(first.to_s)
116
+
117
+ expect(second.items.size).to eq(first.items.size)
118
+ media = second.items.grep(M3u8::MediaItem)
119
+ expect(media.size).to eq(9)
120
+ types = media.map(&:type).uniq.sort
121
+ expect(types).to eq(%w[AUDIO CLOSED-CAPTIONS
122
+ SUBTITLES VIDEO])
123
+ end
124
+
125
+ it 'round-trips gap_playlist.m3u8' do
126
+ text = read_fixture('gap_playlist.m3u8')
127
+ first = parse(text)
128
+ second = parse(first.to_s)
129
+
130
+ expect(second.master?).to be false
131
+ expect(second.items.size).to eq(first.items.size)
132
+
133
+ item = second.items[0]
134
+ expect(item).to be_a(M3u8::BitrateItem)
135
+ expect(item.bitrate).to eq(128)
136
+
137
+ item = second.items[2]
138
+ expect(item).to be_a(M3u8::GapItem)
139
+ end
140
+
141
+ it 'round-trips session_data.m3u8' do
142
+ text = read_fixture('session_data.m3u8')
143
+ first = parse(text)
144
+ second = parse(first.to_s)
145
+
146
+ expect(second.items.size).to eq(first.items.size)
147
+ item = second.items[0]
148
+ expect(item).to be_a(M3u8::SessionDataItem)
149
+ expect(item.data_id).to eq('com.example.lyrics')
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe M3u8::Scte35BitReader do
6
+ describe '#read_bits' do
7
+ it 'should read 8-bit values' do
8
+ reader = described_class.new("\xAB")
9
+ expect(reader.read_bits(8)).to eq(0xAB)
10
+ end
11
+
12
+ it 'should read 1-bit values' do
13
+ reader = described_class.new("\xA0")
14
+ expect(reader.read_bits(1)).to eq(1)
15
+ expect(reader.read_bits(1)).to eq(0)
16
+ expect(reader.read_bits(1)).to eq(1)
17
+ expect(reader.read_bits(1)).to eq(0)
18
+ end
19
+
20
+ it 'should read 12-bit values' do
21
+ reader = described_class.new("\xAB\xCD")
22
+ expect(reader.read_bits(12)).to eq(0xABC)
23
+ end
24
+
25
+ it 'should read 16-bit values' do
26
+ reader = described_class.new("\xAB\xCD")
27
+ expect(reader.read_bits(16)).to eq(0xABCD)
28
+ end
29
+
30
+ it 'should read 32-bit values' do
31
+ reader = described_class.new("\x12\x34\x56\x78")
32
+ expect(reader.read_bits(32)).to eq(0x12345678)
33
+ end
34
+
35
+ it 'should read 33-bit values' do
36
+ # 1 followed by 32 zeros = 2^32
37
+ reader = described_class.new("\x80\x00\x00\x00\x00")
38
+ expect(reader.read_bits(33)).to eq(2**32)
39
+ end
40
+
41
+ it 'should read 33-bit values after partial byte reads' do
42
+ # Simulates: time_specified(1), reserved(6), pts_time(33)
43
+ # 1_111111_0 00000000 00000000 00000000 00000001
44
+ reader = described_class.new("\xFE\x00\x00\x00\x01")
45
+ expect(reader.read_bits(1)).to eq(1)
46
+ expect(reader.read_bits(6)).to eq(0x3F)
47
+ expect(reader.read_bits(33)).to eq(1)
48
+ end
49
+
50
+ it 'should read values across byte boundaries' do
51
+ reader = described_class.new("\xF0\x0F")
52
+ expect(reader.read_bits(4)).to eq(0xF)
53
+ expect(reader.read_bits(8)).to eq(0x00)
54
+ expect(reader.read_bits(4)).to eq(0xF)
55
+ end
56
+ end
57
+
58
+ describe '#read_flag' do
59
+ it 'should return true for 1' do
60
+ reader = described_class.new("\x80")
61
+ expect(reader.read_flag).to be true
62
+ end
63
+
64
+ it 'should return false for 0' do
65
+ reader = described_class.new("\x00")
66
+ expect(reader.read_flag).to be false
67
+ end
68
+ end
69
+
70
+ describe '#read_bytes' do
71
+ it 'should read raw bytes' do
72
+ reader = described_class.new("\xDE\xAD\xBE\xEF")
73
+ expect(reader.read_bytes(2)).to eq("\xDE\xAD".b)
74
+ expect(reader.read_bytes(2)).to eq("\xBE\xEF".b)
75
+ end
76
+ end
77
+
78
+ describe '#skip_bits' do
79
+ it 'should advance the position' do
80
+ reader = described_class.new("\xFF\x42")
81
+ reader.skip_bits(8)
82
+ expect(reader.read_bits(8)).to eq(0x42)
83
+ end
84
+
85
+ it 'should skip sub-byte amounts' do
86
+ reader = described_class.new("\xF5")
87
+ reader.skip_bits(4)
88
+ expect(reader.read_bits(4)).to eq(0x5)
89
+ end
90
+ end
91
+
92
+ describe '#bytes_remaining' do
93
+ it 'should return remaining bytes' do
94
+ reader = described_class.new("\x01\x02\x03\x04")
95
+ expect(reader.bytes_remaining).to eq(4)
96
+ reader.read_bits(8)
97
+ expect(reader.bytes_remaining).to eq(3)
98
+ end
99
+
100
+ it 'should account for partial byte reads' do
101
+ reader = described_class.new("\x01\x02")
102
+ reader.read_bits(4)
103
+ expect(reader.bytes_remaining).to eq(1)
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe M3u8::Scte35SegmentationDescriptor do
6
+ # Builds a full SCTE-35 hex string with a time_signal command and descriptors
7
+ def build_hex_with_descriptors(descriptor_bytes)
8
+ # time_signal with pts=90000: FE 00 01 5F 90 (5 bytes, type 0x06)
9
+ cmd_bytes = 'FE00015F90'
10
+ cmd_len = 5
11
+ desc_loop_length = descriptor_bytes.length
12
+ desc_hex = descriptor_bytes.map { |b| format('%02X', b) }.join
13
+ desc_loop_hex = format('%04X', desc_loop_length)
14
+
15
+ # section_length = 11(header) + cmd_len + 2(desc_loop_len) + desc_loop + 4(CRC)
16
+ section_length = 11 + cmd_len + 2 + desc_loop_length + 4
17
+ header = splice_info_header(section_length, cmd_len)
18
+
19
+ "0x#{header}06#{cmd_bytes}#{desc_loop_hex}#{desc_hex}DEADBEEF"
20
+ end
21
+
22
+ def splice_info_header(section_length, cmd_length)
23
+ section_header = format('%04X', 0x3000 | section_length)
24
+ tier_cmd = format('%06X', (0xFFF << 12) | cmd_length)
25
+ "FC#{section_header}00000000000000#{tier_cmd}"
26
+ end
27
+
28
+ # Builds segmentation_descriptor bytes (tag=0x02, identifier=CUEI)
29
+ def segmentation_descriptor(fields)
30
+ # descriptor_tag(8): 0x02
31
+ # descriptor_length(8): computed
32
+ # identifier(32): CUEI = 0x43554549
33
+ # segmentation_event_id(32)
34
+ # segmentation_event_cancel_indicator(1) + reserved(7)
35
+ # if not cancelled:
36
+ # program_segmentation_flag(1)=1 + segmentation_duration_flag(1)
37
+ # + delivery_not_restricted_flag(1)=1 + reserved(5)=11111
38
+ # if duration_flag: segmentation_duration(40)
39
+ # segmentation_upid_type(8) + segmentation_upid_length(8)
40
+ # + upid data
41
+ # segmentation_type_id(8) + segment_num(8) + segments_expected(8)
42
+ bytes = [0x43, 0x55, 0x45, 0x49] # CUEI identifier
43
+ bytes += to_bytes(fields[:event_id], 4)
44
+
45
+ if fields[:cancel]
46
+ bytes += [0xFF] # cancel=1, reserved=1111111
47
+ else
48
+ bytes << 0x7F # cancel=0, reserved=1111111
49
+ duration_flag = fields[:duration] ? 1 : 0
50
+ # program_seg=1, duration_flag, delivery_not_restricted=1, reserved=11111
51
+ flags = (1 << 7) | (duration_flag << 6) | (1 << 5) | 0x1F
52
+ bytes << flags
53
+
54
+ if fields[:duration]
55
+ bytes += to_bytes(fields[:duration], 5) # 40-bit duration
56
+ end
57
+
58
+ upid = fields[:upid] || ''
59
+ bytes << (fields[:upid_type] || 0)
60
+ bytes << upid.length
61
+ bytes += upid.bytes if upid.length.positive?
62
+
63
+ bytes << fields[:type_id]
64
+ bytes << (fields[:segment_num] || 0)
65
+ bytes << (fields[:segments_expected] || 0)
66
+ end
67
+
68
+ # Prepend tag and length
69
+ [0x02, bytes.length] + bytes
70
+ end
71
+
72
+ def to_bytes(value, count)
73
+ count.times.map { |i| (value >> (8 * (count - 1 - i))) & 0xFF }
74
+ end
75
+
76
+ describe 'parsing via Scte35.parse' do
77
+ it 'should parse a segmentation descriptor with ad-start type' do
78
+ desc = segmentation_descriptor(
79
+ event_id: 0x00000001, type_id: 0x30,
80
+ segment_num: 0, segments_expected: 0
81
+ )
82
+ hex = build_hex_with_descriptors(desc)
83
+ result = M3u8::Scte35.parse(hex)
84
+
85
+ expect(result.descriptors.length).to eq(1)
86
+ seg = result.descriptors.first
87
+ expect(seg).to be_a(described_class)
88
+ expect(seg.segmentation_event_id).to eq(1)
89
+ expect(seg.segmentation_type_id).to eq(0x30)
90
+ expect(seg.segment_num).to eq(0)
91
+ expect(seg.segments_expected).to eq(0)
92
+ end
93
+
94
+ it 'should parse a segmentation descriptor with duration' do
95
+ desc = segmentation_descriptor(
96
+ event_id: 0x00000002, type_id: 0x34,
97
+ duration: 2_700_000, segment_num: 1, segments_expected: 2
98
+ )
99
+ hex = build_hex_with_descriptors(desc)
100
+ result = M3u8::Scte35.parse(hex)
101
+ seg = result.descriptors.first
102
+
103
+ expect(seg.segmentation_duration).to eq(2_700_000)
104
+ expect(seg.segmentation_type_id).to eq(0x34)
105
+ expect(seg.segment_num).to eq(1)
106
+ expect(seg.segments_expected).to eq(2)
107
+ end
108
+
109
+ it 'should parse a segmentation descriptor with UPID' do
110
+ desc = segmentation_descriptor(
111
+ event_id: 0x00000003, type_id: 0x30,
112
+ upid_type: 0x09, upid: 'SIGNAL123'
113
+ )
114
+ hex = build_hex_with_descriptors(desc)
115
+ result = M3u8::Scte35.parse(hex)
116
+ seg = result.descriptors.first
117
+
118
+ expect(seg.segmentation_upid_type).to eq(0x09)
119
+ expect(seg.segmentation_upid).to eq('SIGNAL123')
120
+ end
121
+
122
+ it 'should parse a cancelled segmentation descriptor' do
123
+ desc = segmentation_descriptor(event_id: 0x00000004, cancel: true)
124
+ hex = build_hex_with_descriptors(desc)
125
+ result = M3u8::Scte35.parse(hex)
126
+ seg = result.descriptors.first
127
+
128
+ expect(seg.segmentation_event_id).to eq(4)
129
+ expect(seg.segmentation_event_cancel_indicator).to be true
130
+ expect(seg.segmentation_type_id).to be_nil
131
+ end
132
+
133
+ it 'should store raw bytes for unknown descriptor tags' do
134
+ # Unknown tag 0xFF with 4 bytes of identifier + 2 bytes data
135
+ unknown_desc = [0xFF, 0x06, 0x43, 0x55, 0x45, 0x49, 0xAA, 0xBB]
136
+ hex = build_hex_with_descriptors(unknown_desc)
137
+ result = M3u8::Scte35.parse(hex)
138
+
139
+ expect(result.descriptors.length).to eq(1)
140
+ expect(result.descriptors.first).to be_a(String)
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe M3u8::Scte35 do
6
+ describe '.parse' do
7
+ it 'should parse a splice_null command' do
8
+ # splice_info_section with splice_null (type 0x00):
9
+ # table_id=0xFC, section_length=17, protocol=0, pts_adjustment=0
10
+ # cw_index=0, tier=0xFFF, command_length=0, command_type=0x00
11
+ # descriptor_loop_length=0, crc=0xDEADBEEF
12
+ hex = '0xFC301100000000000000FFF000000000DEADBEEF'
13
+ result = described_class.parse(hex)
14
+
15
+ expect(result.table_id).to eq(0xFC)
16
+ expect(result.pts_adjustment).to eq(0)
17
+ expect(result.tier).to eq(0xFFF)
18
+ expect(result.splice_command_type).to eq(0x00)
19
+ expect(result.splice_command).to be_a(M3u8::Scte35SpliceNull)
20
+ expect(result.descriptors).to eq([])
21
+ end
22
+
23
+ it 'should round-trip hex string via to_s' do
24
+ hex = '0xFC301100000000000000FFF000000000DEADBEEF'
25
+ result = described_class.parse(hex)
26
+ expect(result.to_s).to eq(hex)
27
+ end
28
+
29
+ it 'should store raw bytes for unknown command types' do
30
+ # command_type=0xFF with 2 bytes of command data (0xAABB)
31
+ # section_length=19 to accommodate extra 2 bytes
32
+ hex = '0xFC301300000000000000FFF002FFAABB0000DEADBEEF'
33
+ result = described_class.parse(hex)
34
+
35
+ expect(result.splice_command_type).to eq(0xFF)
36
+ expect(result.splice_command).to eq("\xAA\xBB".b)
37
+ end
38
+
39
+ it 'should parse nonzero pts_adjustment' do
40
+ # pts_adjustment=300 (0x12C): encrypted=0, algo=0, pts=300
41
+ # 0_000000_0_00000000_00000000_00000001_00101100
42
+ # bytes: 00 00 00 01 2C
43
+ hex = '0xFC301100000000012C00FFF000000000DEADBEEF'
44
+ result = described_class.parse(hex)
45
+
46
+ expect(result.pts_adjustment).to eq(300)
47
+ end
48
+
49
+ it 'should handle hex strings without 0x prefix' do
50
+ hex = 'FC301100000000000000FFF000000000DEADBEEF'
51
+ result = described_class.parse(hex)
52
+
53
+ expect(result.table_id).to eq(0xFC)
54
+ expect(result.splice_command_type).to eq(0x00)
55
+ end
56
+
57
+ it 'should parse descriptors with 0xFFF command length' do
58
+ # time_signal (type 0x06) with splice_command_length=0xFFF
59
+ # and a descriptor present
60
+ # time_signal: time_specified=1, pts=90000 (5 bytes)
61
+ # descriptor: tag=0xFF, length=6, identifier=CUEI, data=0xAABB
62
+ hex = '0xFC301E00000000000000FFFFFF06FE00015F900008FF0643554549AABBDEADBEEF'
63
+ result = described_class.parse(hex)
64
+
65
+ expect(result.splice_command_type).to eq(0x06)
66
+ expect(result.splice_command.pts_time).to eq(90_000)
67
+ expect(result.descriptors.length).to eq(1)
68
+ expect(result.descriptors.first).to be_a(String)
69
+ end
70
+
71
+ it 'should consume remaining bytes for unknown type with 0xFFF' do
72
+ # unknown command type 0xFF with 0xFFF length
73
+ # command data is everything up to CRC (no descriptors per spec)
74
+ # section_length = 11(header) + 3(command) + 4(CRC) = 18
75
+ hex = '0xFC301200000000000000FFFFFFFFAABBCCDEADBEEF'
76
+ result = described_class.parse(hex)
77
+
78
+ expect(result.splice_command_type).to eq(0xFF)
79
+ expect(result.splice_command).to eq("\xAA\xBB\xCC".b)
80
+ expect(result.descriptors).to eq([])
81
+ end
82
+
83
+ it 'should raise ParseError for truncated data' do
84
+ hex = '0xFC30'
85
+ expect { described_class.parse(hex) }
86
+ .to raise_error(M3u8::Scte35::ParseError, /invalid SCTE-35 data/)
87
+ end
88
+
89
+ it 'should raise ParseError for empty hex string' do
90
+ expect { described_class.parse('0x') }
91
+ .to raise_error(M3u8::Scte35::ParseError, /invalid SCTE-35 data/)
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe M3u8::Scte35SpliceInsert do
6
+ def build_splice_insert_hex(command_bytes, command_length: nil)
7
+ cmd_len = command_length || command_bytes.length
8
+ # section_length = 11(header) + cmd_len + 2(desc_loop) + 4(CRC)
9
+ section_length = 11 + cmd_len + 6
10
+ header = splice_info_header(section_length, cmd_len)
11
+ command_hex = command_bytes.map { |b| format('%02X', b) }.join
12
+ desc_and_crc = '0000DEADBEEF'
13
+ "0x#{header}05#{command_hex}#{desc_and_crc}"
14
+ end
15
+
16
+ def splice_info_header(section_length, cmd_length)
17
+ table_id = 'FC'
18
+ section_header = format('%04X', 0x3000 | section_length)
19
+ proto_and_pts = '000000000000'
20
+ cw_index = '00'
21
+ tier_cmd = format('%06X', (0xFFF << 12) | cmd_length)
22
+ "#{table_id}#{section_header}#{proto_and_pts}#{cw_index}#{tier_cmd}"
23
+ end
24
+
25
+ describe '.parse_from' do
26
+ it 'should parse an ad-start splice_insert' do
27
+ # splice_event_id(32): 0x00000001
28
+ # splice_event_cancel_indicator(1): 0
29
+ # reserved(7): 1111111
30
+ # out_of_network_indicator(1): 1
31
+ # program_splice_flag(1): 1
32
+ # duration_flag(1): 0
33
+ # splice_immediate_flag(1): 0
34
+ # reserved(4): 1111
35
+ # splice_time: time_specified(1)=1, reserved(6)=111111, pts(33)=90000
36
+ # unique_program_id(16): 0x0001
37
+ # avail_num(8): 0
38
+ # avails_expected(8): 0
39
+ #
40
+ # pts_time 90000 = 0x15F90:
41
+ # time_specified(1)=1, reserved(6)=111111, pts(33)=0x00015F90
42
+ # 1_111111_0_00000000_00000000_00010101_11111001_0000...
43
+ # Wait, 33 bits of 90000 = 0x15F90:
44
+ # binary: 0_00000000_00000001_01011111_10010000
45
+ # with prefix: 1_111111_0_00000000_00000001_01011111_10010000
46
+ # bytes: FE 00 00 01 5F 90 ... but that's 6 bytes (48 bits) for 40 bits
47
+ #
48
+ # Actually: 1+6+33 = 40 bits = 5 bytes:
49
+ # 1_111111_0 00000000 00000001 01011111 10010000
50
+ # = FE 00 01 5F 90
51
+ command_bytes = [
52
+ 0x00, 0x00, 0x00, 0x01, # splice_event_id = 1
53
+ 0x7F, # cancel=0, reserved=1111111
54
+ 0xCF, # out=1, program=1, duration=0, immediate=0, reserved=1111
55
+ 0xFE, 0x00, 0x01, 0x5F, 0x90, # splice_time: specified=1, pts=90000
56
+ 0x00, 0x01, # unique_program_id = 1
57
+ 0x00, # avail_num = 0
58
+ 0x00 # avails_expected = 0
59
+ ]
60
+ hex = build_splice_insert_hex(command_bytes)
61
+ result = M3u8::Scte35.parse(hex)
62
+ cmd = result.splice_command
63
+
64
+ expect(cmd).to be_a(described_class)
65
+ expect(cmd.splice_event_id).to eq(1)
66
+ expect(cmd.out_of_network_indicator).to be true
67
+ expect(cmd.pts_time).to eq(90_000)
68
+ expect(cmd.unique_program_id).to eq(1)
69
+ expect(cmd.avail_num).to eq(0)
70
+ expect(cmd.avails_expected).to eq(0)
71
+ end
72
+
73
+ it 'should parse a cancelled splice event' do
74
+ command_bytes = [
75
+ 0x00, 0x00, 0x00, 0x02, # splice_event_id = 2
76
+ 0xFF # cancel=1, reserved=1111111
77
+ ]
78
+ hex = build_splice_insert_hex(command_bytes)
79
+ result = M3u8::Scte35.parse(hex)
80
+ cmd = result.splice_command
81
+
82
+ expect(cmd.splice_event_id).to eq(2)
83
+ expect(cmd.splice_event_cancel_indicator).to be true
84
+ expect(cmd.out_of_network_indicator).to be_nil
85
+ expect(cmd.pts_time).to be_nil
86
+ end
87
+
88
+ it 'should parse splice_insert with break duration' do
89
+ # out=1, program=1, duration=1, immediate=0, reserved=1111
90
+ # splice_time: specified=1, pts=90000
91
+ # break_duration: auto_return(1)=1, reserved(6)=111111, duration(33)=2700000
92
+ # 2700000 = 0x293370
93
+ # 1_111111_0_00000000_00000000_00101001_00110011_01110000
94
+ # Wait, 33 bits: 0_00000000_00101001_00110011_01110000
95
+ # = 00 29 33 70 in the last 4 bytes, first byte has auto_return+reserved+MSB
96
+ # 1_111111_0 00000000 00101001 00110011 01110000
97
+ # = FE 00 29 33 70
98
+ command_bytes = [
99
+ 0x00, 0x00, 0x00, 0x03, # splice_event_id = 3
100
+ 0x7F, # cancel=0, reserved=1111111
101
+ 0xEF, # out=1, program=1, duration=1, immediate=0, reserved=1111
102
+ 0xFE, 0x00, 0x01, 0x5F, 0x90, # splice_time: specified=1, pts=90000
103
+ 0xFE, 0x00, 0x29, 0x32, 0xE0, # break_duration: auto=1, dur=2700000
104
+ 0x00, 0x01, # unique_program_id = 1
105
+ 0x00, # avail_num = 0
106
+ 0x02 # avails_expected = 2
107
+ ]
108
+ hex = build_splice_insert_hex(command_bytes)
109
+ result = M3u8::Scte35.parse(hex)
110
+ cmd = result.splice_command
111
+
112
+ expect(cmd.break_duration).to eq(2_700_000)
113
+ expect(cmd.break_auto_return).to be true
114
+ expect(cmd.avails_expected).to eq(2)
115
+ end
116
+
117
+ it 'should parse an immediate splice (no PTS)' do
118
+ # out=1, program=1, duration=0, immediate=1, reserved=1111
119
+ command_bytes = [
120
+ 0x00, 0x00, 0x00, 0x04, # splice_event_id = 4
121
+ 0x7F, # cancel=0, reserved=1111111
122
+ 0xDF, # out=1, program=1, duration=0, immediate=1, reserved=1111
123
+ 0x00, 0x01, # unique_program_id = 1
124
+ 0x00, # avail_num = 0
125
+ 0x00 # avails_expected = 0
126
+ ]
127
+ hex = build_splice_insert_hex(command_bytes)
128
+ result = M3u8::Scte35.parse(hex)
129
+ cmd = result.splice_command
130
+
131
+ expect(cmd.pts_time).to be_nil
132
+ expect(cmd.out_of_network_indicator).to be true
133
+ expect(cmd.splice_event_id).to eq(4)
134
+ end
135
+
136
+ it 'should parse component mode splice_insert' do
137
+ # out=1, program=0, duration=0, immediate=0, reserved=1111
138
+ # 1_0_0_0_1111 = 0x8F
139
+ # component_count=1, component_tag=0x22,
140
+ # splice_time: specified=1, pts=90000
141
+ command_bytes = [
142
+ 0x00, 0x00, 0x00, 0x01, # splice_event_id = 1
143
+ 0x7F, # cancel=0, reserved=1111111
144
+ 0x8F, # out=1, program=0, duration=0, immediate=0, reserved=1111
145
+ 0x01, # component_count = 1
146
+ 0x22, # component_tag = 0x22
147
+ 0xFE, 0x00, 0x01, 0x5F, 0x90, # splice_time: specified=1, pts=90000
148
+ 0x00, 0x01, # unique_program_id = 1
149
+ 0x00, # avail_num = 0
150
+ 0x00 # avails_expected = 0
151
+ ]
152
+ hex = build_splice_insert_hex(command_bytes)
153
+ result = M3u8::Scte35.parse(hex)
154
+ cmd = result.splice_command
155
+
156
+ expect(cmd).to be_a(described_class)
157
+ expect(cmd.splice_event_id).to eq(1)
158
+ expect(cmd.out_of_network_indicator).to be true
159
+ expect(cmd.pts_time).to be_nil
160
+ expect(cmd.unique_program_id).to eq(1)
161
+ end
162
+
163
+ it 'should parse component mode with immediate flag' do
164
+ # out=1, program=0, duration=0, immediate=1, reserved=1111
165
+ # 1_0_0_1_1111 = 0x9F
166
+ command_bytes = [
167
+ 0x00, 0x00, 0x00, 0x05, # splice_event_id = 5
168
+ 0x7F, # cancel=0, reserved=1111111
169
+ 0x9F, # out=1, program=0, duration=0, immediate=1, reserved=1111
170
+ 0x02, # component_count = 2
171
+ 0x10, # component_tag = 0x10
172
+ 0x20, # component_tag = 0x20
173
+ 0x00, 0x01, # unique_program_id = 1
174
+ 0x00, # avail_num = 0
175
+ 0x00 # avails_expected = 0
176
+ ]
177
+ hex = build_splice_insert_hex(command_bytes)
178
+ result = M3u8::Scte35.parse(hex)
179
+ cmd = result.splice_command
180
+
181
+ expect(cmd.splice_event_id).to eq(5)
182
+ expect(cmd.unique_program_id).to eq(1)
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe M3u8::Scte35SpliceNull do
6
+ describe '#new' do
7
+ it 'should create an empty splice null command' do
8
+ command = described_class.new
9
+ expect(command).to be_a(described_class)
10
+ end
11
+ end
12
+ end