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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bbf61a694bdc2daf585ad9f405be821f09ba53289ef075e4342f267e674eb21b
4
- data.tar.gz: d599367f1344f9b1ad91c20590bc0b4c57a6d0369c71e3a34fd69325ae0c7f44
3
+ metadata.gz: 3a4ef59c0103a519f686f38d7eb24e99286f698bfbee43d7e8ad48827dfd00e5
4
+ data.tar.gz: b776355294c670836ecc6598ad169d71eedc40e017a686c28042828bf724e805
5
5
  SHA512:
6
- metadata.gz: 9574b7ac1a95fe6b2fabe0bd28576b2b21713a1bed68e2a18d4ea9f644920a91a08e4696df69a19d7b8392eda87fc8fa14b090338ffebe31524cd5f3f4235539
7
- data.tar.gz: 2f34f676d931929f6671b4e3b3ba3efc0e49e73fc2dc54f6c3a64f499e8d2c1a20b974fcad3febcb0f17252d6aef1f9fb612b1c1ec9ad1911e41aa28314f8ea1
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: mixed playlist and segment items
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
@@ -14,7 +14,8 @@ module M3u8
14
14
  @stdout.puts 'Valid'
15
15
  0
16
16
  else
17
- @stdout.puts 'Invalid: mixed playlist and segment items'
17
+ @stdout.puts 'Invalid'
18
+ @playlist.errors.each { |e| @stdout.puts " - #{e}" }
18
19
  1
19
20
  end
20
21
  end
data/lib/m3u8/playlist.rb CHANGED
@@ -59,10 +59,22 @@ module M3u8
59
59
  output.string
60
60
  end
61
61
 
62
- def valid?
63
- return false if playlist_size.positive? && segment_size.positive?
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
- true
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
@@ -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'].to_i,
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  # M3u8 provides parsing, generation, and validation of m3u8 playlists
4
4
  module M3u8
5
- VERSION = '1.3.0'
5
+ VERSION = '1.4.0'
6
6
  end
data/lib/m3u8/writer.rb CHANGED
@@ -44,7 +44,8 @@ module M3u8
44
44
  def validate(playlist)
45
45
  return if playlist.valid?
46
46
 
47
- raise PlaylistTypeError, 'Playlist is invalid.'
47
+ raise PlaylistTypeError,
48
+ "Playlist is invalid: #{playlist.errors.join('; ')}"
48
49
  end
49
50
 
50
51
  def write_cache_tag(cache)
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
- expect(stdout.string.strip).to include('Invalid')
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:10\n" \
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
- uri: 'http://test', language: 'en' }
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",URI="http://test",LANGUAGE="en"\n)
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: 6.2, sequence: 1,
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:6\n" \
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:10\n" \
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, codecs: 'avc',
210
- bandwidth: 540, playlist: 'test.url' }
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.3.0
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-02-28 00:00:00.000000000 Z
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