m3u8 1.3.0 → 1.4.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 +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +37 -1
- data/lib/m3u8/cli/validate_command.rb +2 -1
- data/lib/m3u8/playlist.rb +87 -3
- data/lib/m3u8/playlist_item.rb +7 -1
- data/lib/m3u8/version.rb +1 -1
- data/lib/m3u8/writer.rb +2 -1
- data/m3u8.gemspec +1 -0
- data/spec/lib/m3u8/cli/validate_command_spec.rb +6 -2
- data/spec/lib/m3u8/playlist_item_spec.rb +6 -0
- data/spec/lib/m3u8/playlist_spec.rb +327 -2
- data/spec/lib/m3u8/writer_spec.rb +11 -10
- metadata +2 -4
- data/AGENTS.md +0 -27
- data/CLAUDE.md +0 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3a4ef59c0103a519f686f38d7eb24e99286f698bfbee43d7e8ad48827dfd00e5
|
|
4
|
+
data.tar.gz: b776355294c670836ecc6598ad169d71eedc40e017a686c28042828bf724e805
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e2f761adbf62244f9f175fc28f7f00f0d77d78acb5af19906f70fff656777e97bbbe787454ce47accd0e72f4e702baeda20dde763a72ac87174cb9ccd24dca15
|
|
7
|
+
data.tar.gz: bf19a4de08cc5216b939c77eedc147e93e134bc284a90464f138f3a31f0d679d88e5bd6e306db63dbf0e26ce7a40fba618bb67d41c15443899a2824b6fd4776c
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
**1.4.0**
|
|
2
|
+
|
|
3
|
+
* 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.
|
|
4
|
+
* Updated CLI `validate` command to display specific error messages.
|
|
5
|
+
* Updated `Writer` to include specific errors in the exception message.
|
|
6
|
+
|
|
7
|
+
***
|
|
8
|
+
|
|
9
|
+
**1.3.1**
|
|
10
|
+
|
|
11
|
+
* Excluded CLAUDE.md and AGENTS.md from gem package.
|
|
12
|
+
|
|
13
|
+
***
|
|
14
|
+
|
|
1
15
|
**1.3.0**
|
|
2
16
|
|
|
3
17
|
* 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.
|
data/README.md
CHANGED
|
@@ -79,7 +79,8 @@ $ m3u8 validate playlist.m3u8
|
|
|
79
79
|
Valid
|
|
80
80
|
|
|
81
81
|
$ m3u8 validate bad.m3u8
|
|
82
|
-
Invalid
|
|
82
|
+
Invalid
|
|
83
|
+
- Playlist contains both master and media items
|
|
83
84
|
```
|
|
84
85
|
|
|
85
86
|
## Usage (Builder DSL)
|
|
@@ -343,6 +344,41 @@ options = { width: 1920, height: 1080, codecs: 'avc1.66.30,mp4a.40.2',
|
|
|
343
344
|
item = M3u8::PlaylistItem.new(options)
|
|
344
345
|
```
|
|
345
346
|
|
|
347
|
+
## Validation
|
|
348
|
+
|
|
349
|
+
Check whether a playlist is valid and inspect specific errors:
|
|
350
|
+
|
|
351
|
+
```ruby
|
|
352
|
+
playlist.valid?
|
|
353
|
+
# => true
|
|
354
|
+
|
|
355
|
+
playlist.errors
|
|
356
|
+
# => []
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
When a playlist has issues, `errors` returns descriptive messages:
|
|
360
|
+
|
|
361
|
+
```ruby
|
|
362
|
+
playlist.valid?
|
|
363
|
+
# => false
|
|
364
|
+
|
|
365
|
+
playlist.errors
|
|
366
|
+
# => ["Playlist contains both master and media items"]
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
The following validations are performed:
|
|
370
|
+
|
|
371
|
+
* Mixed item types (both master and media items in one playlist)
|
|
372
|
+
* Target duration less than any segment's rounded duration
|
|
373
|
+
* Segment items missing a URI or having a negative duration
|
|
374
|
+
* Playlist items missing a URI or valid bandwidth
|
|
375
|
+
* Media items missing type, group ID, or name
|
|
376
|
+
* Key and session key items missing a URI when method is not NONE
|
|
377
|
+
* Session data items missing data ID, or having both/neither value and URI
|
|
378
|
+
* Part items missing a URI or duration
|
|
379
|
+
|
|
380
|
+
`valid?` delegates to `errors.empty?` and both are recomputed on each call.
|
|
381
|
+
|
|
346
382
|
## Usage (parsing playlists)
|
|
347
383
|
|
|
348
384
|
```ruby
|
data/lib/m3u8/playlist.rb
CHANGED
|
@@ -59,10 +59,22 @@ module M3u8
|
|
|
59
59
|
output.string
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
-
def
|
|
63
|
-
|
|
62
|
+
def errors
|
|
63
|
+
[].tap do |errors|
|
|
64
|
+
validate_mixed_items(errors)
|
|
65
|
+
validate_target_duration(errors)
|
|
66
|
+
validate_segment_items(errors)
|
|
67
|
+
validate_playlist_items(errors)
|
|
68
|
+
validate_media_items(errors)
|
|
69
|
+
validate_key_items(errors)
|
|
70
|
+
validate_session_key_items(errors)
|
|
71
|
+
validate_session_data_items(errors)
|
|
72
|
+
validate_part_items(errors)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
64
75
|
|
|
65
|
-
|
|
76
|
+
def valid?
|
|
77
|
+
errors.empty?
|
|
66
78
|
end
|
|
67
79
|
|
|
68
80
|
def segments
|
|
@@ -134,6 +146,78 @@ module M3u8
|
|
|
134
146
|
}
|
|
135
147
|
end
|
|
136
148
|
|
|
149
|
+
def validate_part_items(errors)
|
|
150
|
+
parts.each do |item|
|
|
151
|
+
errors << 'Part item requires a URI' if item.uri.nil?
|
|
152
|
+
errors << 'Part item requires a duration' if item.duration.nil?
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def validate_session_data_items(errors)
|
|
157
|
+
session_data.each do |item|
|
|
158
|
+
errors << 'Session data item requires a data ID' if item.data_id.nil?
|
|
159
|
+
if !item.value.nil? && !item.uri.nil?
|
|
160
|
+
errors << 'Session data item cannot have both value and URI'
|
|
161
|
+
elsif item.value.nil? && item.uri.nil?
|
|
162
|
+
errors << 'Session data item requires a value or URI'
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def validate_key_items(errors)
|
|
168
|
+
keys.each do |item|
|
|
169
|
+
next if item.method == 'NONE'
|
|
170
|
+
|
|
171
|
+
errors << 'Key item requires a URI when method is not NONE' if item.uri.nil?
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def validate_session_key_items(errors)
|
|
176
|
+
session_keys.each do |item|
|
|
177
|
+
next if item.method == 'NONE'
|
|
178
|
+
|
|
179
|
+
errors << 'Session key item requires a URI when method is not NONE' if item.uri.nil?
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def validate_playlist_items(errors)
|
|
184
|
+
playlists.each do |item|
|
|
185
|
+
errors << 'Playlist item requires a bandwidth' unless item.bandwidth&.positive?
|
|
186
|
+
errors << 'Playlist item requires a URI' if item.uri.nil?
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def validate_media_items(errors)
|
|
191
|
+
media_items.each do |item|
|
|
192
|
+
errors << 'Media item requires a type' if item.type.nil?
|
|
193
|
+
errors << 'Media item requires a group ID' if item.group_id.nil?
|
|
194
|
+
errors << 'Media item requires a name' if item.name.nil?
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def validate_segment_items(errors)
|
|
199
|
+
segments.each do |segment|
|
|
200
|
+
errors << 'Segment item requires a segment URI' if segment.segment.nil?
|
|
201
|
+
errors << 'Segment item has negative duration' if segment.duration&.negative?
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def validate_target_duration(errors)
|
|
206
|
+
return if master?
|
|
207
|
+
|
|
208
|
+
max = segments.filter_map { |s| s.duration&.round }.max
|
|
209
|
+
return if max.nil? || target >= max
|
|
210
|
+
|
|
211
|
+
errors << "Target duration #{target} is less than " \
|
|
212
|
+
"segment duration of #{max}"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def validate_mixed_items(errors)
|
|
216
|
+
return unless playlist_size.positive? && segment_size.positive?
|
|
217
|
+
|
|
218
|
+
errors << 'Playlist contains both master and media items'
|
|
219
|
+
end
|
|
220
|
+
|
|
137
221
|
def playlist_size
|
|
138
222
|
playlists.size
|
|
139
223
|
end
|
data/lib/m3u8/playlist_item.rb
CHANGED
|
@@ -69,7 +69,7 @@ module M3u8
|
|
|
69
69
|
codecs: attributes['CODECS'],
|
|
70
70
|
width: resolution[:width],
|
|
71
71
|
height: resolution[:height],
|
|
72
|
-
bandwidth: attributes['BANDWIDTH']
|
|
72
|
+
bandwidth: parse_bandwidth(attributes['BANDWIDTH']),
|
|
73
73
|
average_bandwidth:
|
|
74
74
|
parse_average_bandwidth(attributes['AVERAGE-BANDWIDTH']),
|
|
75
75
|
frame_rate: parse_frame_rate(attributes['FRAME-RATE']),
|
|
@@ -90,6 +90,12 @@ module M3u8
|
|
|
90
90
|
value&.to_i
|
|
91
91
|
end
|
|
92
92
|
|
|
93
|
+
def parse_bandwidth(value)
|
|
94
|
+
return if value.nil?
|
|
95
|
+
|
|
96
|
+
value.to_i
|
|
97
|
+
end
|
|
98
|
+
|
|
93
99
|
def parse_resolution(resolution)
|
|
94
100
|
return { width: nil, height: nil } if resolution.nil?
|
|
95
101
|
|
data/lib/m3u8/version.rb
CHANGED
data/lib/m3u8/writer.rb
CHANGED
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
|
|
|
@@ -18,7 +18,7 @@ describe M3u8::CLI::ValidateCommand do
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
context 'when playlist is invalid' do
|
|
21
|
-
it 'prints Invalid and returns 1' do
|
|
21
|
+
it 'prints Invalid with specific errors and returns 1' do
|
|
22
22
|
playlist = M3u8::Playlist.new
|
|
23
23
|
playlist.items << M3u8::PlaylistItem.new(
|
|
24
24
|
bandwidth: 540, uri: 'test.url'
|
|
@@ -28,7 +28,11 @@ describe M3u8::CLI::ValidateCommand do
|
|
|
28
28
|
)
|
|
29
29
|
code = described_class.new(playlist, stdout).run
|
|
30
30
|
expect(code).to eq(1)
|
|
31
|
-
|
|
31
|
+
lines = stdout.string.split("\n")
|
|
32
|
+
expect(lines[0]).to eq('Invalid')
|
|
33
|
+
expect(lines[1]).to eq(
|
|
34
|
+
' - Playlist contains both master and media items'
|
|
35
|
+
)
|
|
32
36
|
end
|
|
33
37
|
end
|
|
34
38
|
end
|
|
@@ -94,6 +94,12 @@ describe M3u8::PlaylistItem do
|
|
|
94
94
|
expect(item.supplemental_codecs).to eq('dvh1.05.06/db4g')
|
|
95
95
|
expect(item.req_video_layout).to eq('CH-MONO')
|
|
96
96
|
end
|
|
97
|
+
|
|
98
|
+
it 'keeps bandwidth nil when BANDWIDTH is missing' do
|
|
99
|
+
input = '#EXT-X-STREAM-INF:CODECS="avc",URI="test.url"'
|
|
100
|
+
item = M3u8::PlaylistItem.parse(input)
|
|
101
|
+
expect(item.bandwidth).to be_nil
|
|
102
|
+
end
|
|
97
103
|
end
|
|
98
104
|
|
|
99
105
|
describe '#to_s' do
|
|
@@ -182,6 +182,7 @@ describe M3u8::Playlist do
|
|
|
182
182
|
end
|
|
183
183
|
|
|
184
184
|
it 'returns media playlist text' do
|
|
185
|
+
playlist = described_class.new(target: 12)
|
|
185
186
|
options = { duration: 11.344644, segment: '1080-7mbps00000.ts' }
|
|
186
187
|
item = M3u8::SegmentItem.new(options)
|
|
187
188
|
playlist.items << item
|
|
@@ -192,7 +193,7 @@ describe M3u8::Playlist do
|
|
|
192
193
|
|
|
193
194
|
expected = "#EXTM3U\n" \
|
|
194
195
|
"#EXT-X-MEDIA-SEQUENCE:0\n" \
|
|
195
|
-
"#EXT-X-TARGETDURATION:
|
|
196
|
+
"#EXT-X-TARGETDURATION:12\n" \
|
|
196
197
|
"#EXTINF:11.344644,\n" \
|
|
197
198
|
"1080-7mbps00000.ts\n" \
|
|
198
199
|
"#EXTINF:11.261233,\n" \
|
|
@@ -237,6 +238,329 @@ describe M3u8::Playlist do
|
|
|
237
238
|
end
|
|
238
239
|
end
|
|
239
240
|
|
|
241
|
+
describe '#errors' do
|
|
242
|
+
context 'when playlist is empty' do
|
|
243
|
+
it 'returns no errors' do
|
|
244
|
+
expect(playlist.errors).to be_empty
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
context 'when playlist has only master items' do
|
|
249
|
+
it 'returns no errors' do
|
|
250
|
+
playlist.items << M3u8::PlaylistItem.new(
|
|
251
|
+
bandwidth: 540, uri: 'test.url'
|
|
252
|
+
)
|
|
253
|
+
expect(playlist.errors).to be_empty
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
context 'when playlist has only media items' do
|
|
258
|
+
it 'returns no errors' do
|
|
259
|
+
playlist.items << M3u8::SegmentItem.new(
|
|
260
|
+
duration: 10.0, segment: 'test.ts'
|
|
261
|
+
)
|
|
262
|
+
expect(playlist.errors).to be_empty
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
context 'when segment duration exceeds target duration' do
|
|
267
|
+
it 'returns target duration error' do
|
|
268
|
+
playlist = described_class.new(target: 10)
|
|
269
|
+
playlist.items << M3u8::SegmentItem.new(
|
|
270
|
+
duration: 12.1, segment: 'test.ts'
|
|
271
|
+
)
|
|
272
|
+
expect(playlist.errors).to include(
|
|
273
|
+
'Target duration 10 is less than segment duration of 12'
|
|
274
|
+
)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
context 'when segment duration rounds to target' do
|
|
279
|
+
it 'returns no errors' do
|
|
280
|
+
playlist = described_class.new(target: 11)
|
|
281
|
+
playlist.items << M3u8::SegmentItem.new(
|
|
282
|
+
duration: 10.5, segment: 'test.ts'
|
|
283
|
+
)
|
|
284
|
+
expect(playlist.errors).to be_empty
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
context 'when playlist is master' do
|
|
289
|
+
it 'skips target duration check' do
|
|
290
|
+
playlist = described_class.new(master: true)
|
|
291
|
+
expect(playlist.errors).to be_empty
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
context 'when segment has no URI' do
|
|
296
|
+
it 'returns missing segment error' do
|
|
297
|
+
playlist.items << M3u8::SegmentItem.new(duration: 10.0)
|
|
298
|
+
expect(playlist.errors).to include(
|
|
299
|
+
'Segment item requires a segment URI'
|
|
300
|
+
)
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
context 'when segment has negative duration' do
|
|
305
|
+
it 'returns negative duration error' do
|
|
306
|
+
playlist.items << M3u8::SegmentItem.new(
|
|
307
|
+
duration: -1.0, segment: 'test.ts'
|
|
308
|
+
)
|
|
309
|
+
expect(playlist.errors).to include(
|
|
310
|
+
'Segment item has negative duration'
|
|
311
|
+
)
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
context 'when segment has zero duration' do
|
|
316
|
+
it 'returns no errors' do
|
|
317
|
+
playlist.items << M3u8::SegmentItem.new(
|
|
318
|
+
duration: 0.0, segment: 'test.ts'
|
|
319
|
+
)
|
|
320
|
+
expect(playlist.errors).to be_empty
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
context 'when multiple segments are invalid' do
|
|
325
|
+
it 'accumulates errors' do
|
|
326
|
+
playlist.items << M3u8::SegmentItem.new(duration: 10.0)
|
|
327
|
+
playlist.items << M3u8::SegmentItem.new(
|
|
328
|
+
duration: -1.0, segment: 'test.ts'
|
|
329
|
+
)
|
|
330
|
+
errors = playlist.errors
|
|
331
|
+
expect(errors).to include(
|
|
332
|
+
'Segment item requires a segment URI'
|
|
333
|
+
)
|
|
334
|
+
expect(errors).to include(
|
|
335
|
+
'Segment item has negative duration'
|
|
336
|
+
)
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
context 'when playlist item has no bandwidth' do
|
|
341
|
+
it 'returns missing bandwidth error' do
|
|
342
|
+
playlist.items << M3u8::PlaylistItem.new(uri: 'test.url')
|
|
343
|
+
expect(playlist.errors).to include(
|
|
344
|
+
'Playlist item requires a bandwidth'
|
|
345
|
+
)
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
context 'when playlist item has no URI and is not iframe' do
|
|
350
|
+
it 'returns missing URI error' do
|
|
351
|
+
playlist.items << M3u8::PlaylistItem.new(bandwidth: 540)
|
|
352
|
+
expect(playlist.errors).to include(
|
|
353
|
+
'Playlist item requires a URI'
|
|
354
|
+
)
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
context 'when playlist item has zero bandwidth' do
|
|
359
|
+
it 'returns missing bandwidth error' do
|
|
360
|
+
playlist.items << M3u8::PlaylistItem.new(
|
|
361
|
+
bandwidth: 0, uri: 'test.url'
|
|
362
|
+
)
|
|
363
|
+
expect(playlist.errors).to include(
|
|
364
|
+
'Playlist item requires a bandwidth'
|
|
365
|
+
)
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
context 'when iframe playlist item has no URI' do
|
|
370
|
+
it 'returns missing URI error' do
|
|
371
|
+
playlist.items << M3u8::PlaylistItem.new(
|
|
372
|
+
bandwidth: 540, iframe: true
|
|
373
|
+
)
|
|
374
|
+
expect(playlist.errors).to include(
|
|
375
|
+
'Playlist item requires a URI'
|
|
376
|
+
)
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
context 'when media item is missing type' do
|
|
381
|
+
it 'returns missing type error' do
|
|
382
|
+
playlist.items << M3u8::PlaylistItem.new(
|
|
383
|
+
bandwidth: 540, uri: 'test.url'
|
|
384
|
+
)
|
|
385
|
+
playlist.items << M3u8::MediaItem.new(
|
|
386
|
+
group_id: 'audio', name: 'English'
|
|
387
|
+
)
|
|
388
|
+
expect(playlist.errors).to include(
|
|
389
|
+
'Media item requires a type'
|
|
390
|
+
)
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
context 'when media item is missing group_id' do
|
|
395
|
+
it 'returns missing group_id error' do
|
|
396
|
+
playlist.items << M3u8::PlaylistItem.new(
|
|
397
|
+
bandwidth: 540, uri: 'test.url'
|
|
398
|
+
)
|
|
399
|
+
playlist.items << M3u8::MediaItem.new(
|
|
400
|
+
type: 'AUDIO', name: 'English'
|
|
401
|
+
)
|
|
402
|
+
expect(playlist.errors).to include(
|
|
403
|
+
'Media item requires a group ID'
|
|
404
|
+
)
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
context 'when media item is missing name' do
|
|
409
|
+
it 'returns missing name error' do
|
|
410
|
+
playlist.items << M3u8::PlaylistItem.new(
|
|
411
|
+
bandwidth: 540, uri: 'test.url'
|
|
412
|
+
)
|
|
413
|
+
playlist.items << M3u8::MediaItem.new(
|
|
414
|
+
type: 'AUDIO', group_id: 'audio'
|
|
415
|
+
)
|
|
416
|
+
expect(playlist.errors).to include(
|
|
417
|
+
'Media item requires a name'
|
|
418
|
+
)
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
context 'when key item has method but no URI' do
|
|
423
|
+
it 'returns missing URI error' do
|
|
424
|
+
playlist.items << M3u8::SegmentItem.new(
|
|
425
|
+
duration: 10.0, segment: 'test.ts'
|
|
426
|
+
)
|
|
427
|
+
playlist.items << M3u8::KeyItem.new(method: 'AES-128')
|
|
428
|
+
expect(playlist.errors).to include(
|
|
429
|
+
'Key item requires a URI when method is not NONE'
|
|
430
|
+
)
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
context 'when key item method is NONE' do
|
|
435
|
+
it 'returns no errors' do
|
|
436
|
+
playlist.items << M3u8::SegmentItem.new(
|
|
437
|
+
duration: 10.0, segment: 'test.ts'
|
|
438
|
+
)
|
|
439
|
+
playlist.items << M3u8::KeyItem.new(method: 'NONE')
|
|
440
|
+
expect(playlist.errors).to be_empty
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
context 'when session key item has method but no URI' do
|
|
445
|
+
it 'returns missing URI error' do
|
|
446
|
+
playlist.items << M3u8::PlaylistItem.new(
|
|
447
|
+
bandwidth: 540, uri: 'test.url'
|
|
448
|
+
)
|
|
449
|
+
playlist.items << M3u8::SessionKeyItem.new(
|
|
450
|
+
method: 'AES-128'
|
|
451
|
+
)
|
|
452
|
+
expect(playlist.errors).to include(
|
|
453
|
+
'Session key item requires a URI when method is not NONE'
|
|
454
|
+
)
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
context 'when session data item has no data_id' do
|
|
459
|
+
it 'returns missing data_id error' do
|
|
460
|
+
playlist.items << M3u8::PlaylistItem.new(
|
|
461
|
+
bandwidth: 540, uri: 'test.url'
|
|
462
|
+
)
|
|
463
|
+
playlist.items << M3u8::SessionDataItem.new(
|
|
464
|
+
value: 'Test'
|
|
465
|
+
)
|
|
466
|
+
expect(playlist.errors).to include(
|
|
467
|
+
'Session data item requires a data ID'
|
|
468
|
+
)
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
context 'when session data item has both value and uri' do
|
|
473
|
+
it 'returns conflict error' do
|
|
474
|
+
playlist.items << M3u8::PlaylistItem.new(
|
|
475
|
+
bandwidth: 540, uri: 'test.url'
|
|
476
|
+
)
|
|
477
|
+
playlist.items << M3u8::SessionDataItem.new(
|
|
478
|
+
data_id: 'com.test', value: 'Test',
|
|
479
|
+
uri: 'http://test'
|
|
480
|
+
)
|
|
481
|
+
expect(playlist.errors).to include(
|
|
482
|
+
'Session data item cannot have both value and URI'
|
|
483
|
+
)
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
context 'when session data item has neither value nor uri' do
|
|
488
|
+
it 'returns missing value error' do
|
|
489
|
+
playlist.items << M3u8::PlaylistItem.new(
|
|
490
|
+
bandwidth: 540, uri: 'test.url'
|
|
491
|
+
)
|
|
492
|
+
playlist.items << M3u8::SessionDataItem.new(
|
|
493
|
+
data_id: 'com.test'
|
|
494
|
+
)
|
|
495
|
+
expect(playlist.errors).to include(
|
|
496
|
+
'Session data item requires a value or URI'
|
|
497
|
+
)
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
context 'when session data item has only value' do
|
|
502
|
+
it 'returns no session data errors' do
|
|
503
|
+
playlist.items << M3u8::PlaylistItem.new(
|
|
504
|
+
bandwidth: 540, uri: 'test.url'
|
|
505
|
+
)
|
|
506
|
+
playlist.items << M3u8::SessionDataItem.new(
|
|
507
|
+
data_id: 'com.test', value: 'Test'
|
|
508
|
+
)
|
|
509
|
+
expect(playlist.errors).to be_empty
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
context 'when part item has no URI' do
|
|
514
|
+
it 'returns missing URI error' do
|
|
515
|
+
playlist.items << M3u8::SegmentItem.new(
|
|
516
|
+
duration: 4.0, segment: 'seg.mp4'
|
|
517
|
+
)
|
|
518
|
+
playlist.items << M3u8::PartItem.new(duration: 0.5)
|
|
519
|
+
expect(playlist.errors).to include(
|
|
520
|
+
'Part item requires a URI'
|
|
521
|
+
)
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
context 'when part item has no duration' do
|
|
526
|
+
it 'returns missing duration error' do
|
|
527
|
+
playlist.items << M3u8::SegmentItem.new(
|
|
528
|
+
duration: 4.0, segment: 'seg.mp4'
|
|
529
|
+
)
|
|
530
|
+
playlist.items << M3u8::PartItem.new(uri: 'seg.0.mp4')
|
|
531
|
+
expect(playlist.errors).to include(
|
|
532
|
+
'Part item requires a duration'
|
|
533
|
+
)
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
context 'when part item is valid' do
|
|
538
|
+
it 'returns no part errors' do
|
|
539
|
+
playlist.items << M3u8::SegmentItem.new(
|
|
540
|
+
duration: 4.0, segment: 'seg.mp4'
|
|
541
|
+
)
|
|
542
|
+
playlist.items << M3u8::PartItem.new(
|
|
543
|
+
duration: 0.5, uri: 'seg.0.mp4'
|
|
544
|
+
)
|
|
545
|
+
expect(playlist.errors).to be_empty
|
|
546
|
+
end
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
context 'when playlist has mixed items' do
|
|
550
|
+
it 'returns mixed items error' do
|
|
551
|
+
playlist.items << M3u8::PlaylistItem.new(
|
|
552
|
+
bandwidth: 540, uri: 'test.url'
|
|
553
|
+
)
|
|
554
|
+
playlist.items << M3u8::SegmentItem.new(
|
|
555
|
+
duration: 10.0, segment: 'test.ts'
|
|
556
|
+
)
|
|
557
|
+
expect(playlist.errors).to include(
|
|
558
|
+
'Playlist contains both master and media items'
|
|
559
|
+
)
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
|
|
240
564
|
describe '#segments' do
|
|
241
565
|
it 'returns only segment items' do
|
|
242
566
|
playlist = described_class.read(
|
|
@@ -356,7 +680,8 @@ describe M3u8::Playlist do
|
|
|
356
680
|
item = M3u8::SegmentItem.new(options)
|
|
357
681
|
playlist.items << item
|
|
358
682
|
|
|
359
|
-
message = 'Playlist is invalid
|
|
683
|
+
message = 'Playlist is invalid: Playlist contains both ' \
|
|
684
|
+
'master and media items'
|
|
360
685
|
io = StringIO.new
|
|
361
686
|
expect { playlist.write(io) }
|
|
362
687
|
.to raise_error(M3u8::PlaylistTypeError, message)
|
|
@@ -17,7 +17,7 @@ describe M3u8::Writer do
|
|
|
17
17
|
item = M3u8::PlaylistItem.new(options)
|
|
18
18
|
playlist.items << item
|
|
19
19
|
options = { data_id: 'com.test.movie.title', value: 'Test',
|
|
20
|
-
|
|
20
|
+
language: 'en' }
|
|
21
21
|
item = M3u8::SessionDataItem.new(options)
|
|
22
22
|
playlist.items << item
|
|
23
23
|
|
|
@@ -28,7 +28,7 @@ describe M3u8::Writer do
|
|
|
28
28
|
%(RESOLUTION=1920x1080,CODECS="avc1.640029,mp4a.40.2") +
|
|
29
29
|
",BANDWIDTH=50000\nplaylist_url\n" +
|
|
30
30
|
%(#EXT-X-SESSION-DATA:DATA-ID="com.test.movie.title",) +
|
|
31
|
-
%(VALUE="Test",
|
|
31
|
+
%(VALUE="Test",LANGUAGE="en"\n)
|
|
32
32
|
|
|
33
33
|
io = StringIO.open
|
|
34
34
|
writer = described_class.new(io)
|
|
@@ -101,7 +101,7 @@ describe M3u8::Writer do
|
|
|
101
101
|
|
|
102
102
|
context 'when playlist is a media playlist' do
|
|
103
103
|
it 'writes playlist to io' do
|
|
104
|
-
options = { version: 4, cache: false, target:
|
|
104
|
+
options = { version: 4, cache: false, target: 12, sequence: 1,
|
|
105
105
|
discontinuity_sequence: 10, type: 'EVENT',
|
|
106
106
|
iframes_only: true }
|
|
107
107
|
playlist = M3u8::Playlist.new(options)
|
|
@@ -116,7 +116,7 @@ describe M3u8::Writer do
|
|
|
116
116
|
"#EXT-X-MEDIA-SEQUENCE:1\n" \
|
|
117
117
|
"#EXT-X-DISCONTINUITY-SEQUENCE:10\n" \
|
|
118
118
|
"#EXT-X-ALLOW-CACHE:NO\n" \
|
|
119
|
-
"#EXT-X-TARGETDURATION:
|
|
119
|
+
"#EXT-X-TARGETDURATION:12\n" \
|
|
120
120
|
"#EXTINF:11.344644,\n" \
|
|
121
121
|
"1080-7mbps00000.ts\n" \
|
|
122
122
|
"#EXT-X-ENDLIST\n"
|
|
@@ -132,7 +132,7 @@ describe M3u8::Writer do
|
|
|
132
132
|
it 'writes playlist to io' do
|
|
133
133
|
options = { duration: 11.344644, segment: '1080-7mbps00000.ts' }
|
|
134
134
|
item = M3u8::SegmentItem.new(options)
|
|
135
|
-
playlist = M3u8::Playlist.new(version: 7)
|
|
135
|
+
playlist = M3u8::Playlist.new(version: 7, target: 12)
|
|
136
136
|
playlist.items << item
|
|
137
137
|
|
|
138
138
|
options = { method: 'AES-128', uri: 'http://test.key',
|
|
@@ -148,7 +148,7 @@ describe M3u8::Writer do
|
|
|
148
148
|
expected = "#EXTM3U\n" \
|
|
149
149
|
"#EXT-X-VERSION:7\n" \
|
|
150
150
|
"#EXT-X-MEDIA-SEQUENCE:0\n" \
|
|
151
|
-
"#EXT-X-TARGETDURATION:
|
|
151
|
+
"#EXT-X-TARGETDURATION:12\n" \
|
|
152
152
|
"#EXTINF:11.344644,\n" \
|
|
153
153
|
"1080-7mbps00000.ts\n" +
|
|
154
154
|
%(#EXT-X-KEY:METHOD=AES-128,URI="http://test.key",) +
|
|
@@ -204,10 +204,10 @@ describe M3u8::Writer do
|
|
|
204
204
|
end
|
|
205
205
|
end
|
|
206
206
|
|
|
207
|
-
it 'raises error if item types are mixed' do
|
|
207
|
+
it 'raises error with specific messages if item types are mixed' do
|
|
208
208
|
playlist = M3u8::Playlist.new
|
|
209
|
-
options = { program_id: 1, width: 1920, height: 1080,
|
|
210
|
-
bandwidth: 540,
|
|
209
|
+
options = { program_id: 1, width: 1920, height: 1080,
|
|
210
|
+
codecs: 'avc', bandwidth: 540, uri: 'test.url' }
|
|
211
211
|
item = M3u8::PlaylistItem.new(options)
|
|
212
212
|
playlist.items << item
|
|
213
213
|
|
|
@@ -215,7 +215,8 @@ describe M3u8::Writer do
|
|
|
215
215
|
item = M3u8::SegmentItem.new(options)
|
|
216
216
|
playlist.items << item
|
|
217
217
|
|
|
218
|
-
message = 'Playlist is invalid
|
|
218
|
+
message = 'Playlist is invalid: Playlist contains both ' \
|
|
219
|
+
'master and media items'
|
|
219
220
|
io = StringIO.new
|
|
220
221
|
writer = described_class.new(io)
|
|
221
222
|
expect { writer.write(playlist) }
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: m3u8
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Seth Deckard
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-03-01 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: bundler
|
|
@@ -135,9 +135,7 @@ files:
|
|
|
135
135
|
- ".hound.yml"
|
|
136
136
|
- ".rspec"
|
|
137
137
|
- ".rubocop.yml"
|
|
138
|
-
- AGENTS.md
|
|
139
138
|
- CHANGELOG.md
|
|
140
|
-
- CLAUDE.md
|
|
141
139
|
- Gemfile
|
|
142
140
|
- Guardfile
|
|
143
141
|
- LICENSE.txt
|
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
|