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
data/README.md CHANGED
@@ -1,18 +1,18 @@
1
1
  [![Gem Version](https://badge.fury.io/rb/m3u8.svg)](http://badge.fury.io/rb/m3u8)
2
- [![Build Status](https://travis-ci.org/sethdeckard/m3u8.svg?branch=master)](https://travis-ci.org/sethdeckard/m3u8)
3
- [![Coverage Status](https://coveralls.io/repos/github/sethdeckard/m3u8/badge.svg?branch=master)](https://coveralls.io/github/sethdeckard/m3u8?branch=master)
4
- [![Code Climate](https://codeclimate.com/github/sethdeckard/m3u8/badges/gpa.svg)](https://codeclimate.com/github/sethdeckard/m3u8)
5
- [![Dependency Status](https://gemnasium.com/sethdeckard/m3u8.svg)](https://gemnasium.com/sethdeckard/m3u8)
6
- [![security](https://hakiri.io/github/sethdeckard/m3u8/master.svg)](https://hakiri.io/github/sethdeckard/m3u8/master)
2
+ [![CI](https://github.com/sethdeckard/m3u8/actions/workflows/ci.yml/badge.svg)](https://github.com/sethdeckard/m3u8/actions/workflows/ci.yml)
7
3
  # m3u8
8
4
 
9
- m3u8 provides easy generation and parsing of m3u8 playlists defined in the [HTTP Live Streaming (HLS)](https://tools.ietf.org/html/draft-pantos-http-live-streaming-20) Internet Draft published by Apple.
5
+ m3u8 provides easy generation and parsing of m3u8 playlists defined in [RFC 8216 HTTP Live Streaming](https://www.rfc-editor.org/rfc/rfc8216) and its proposed successor [draft-pantos-hls-rfc8216bis](https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis).
10
6
 
11
- * The library completely implements version 20 of the HLS Internet Draft.
7
+ * Full coverage of [RFC 8216](https://www.rfc-editor.org/rfc/rfc8216) and [draft-pantos-hls-rfc8216bis-19](https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-19) (Protocol Version 13), including Low-Latency HLS and Content Steering.
12
8
  * Provides parsing of an m3u8 playlist into an object model from any File, StringIO, or string.
13
9
  * Provides ability to write playlist to a File or StringIO or expose as string via to_s.
14
10
  * Distinction between a master and media playlist is handled automatically (single Playlist class).
15
- * Optionally, the library can automatically generate the audio/video codecs string used in the CODEC attribute based on specified H.264, AAC, or MP3 options (such as Profile/Level).
11
+ * Automatic generation of codec strings for H.264, HEVC, AV1, AAC, AC-3, E-AC-3, FLAC, Opus, and MP3.
12
+
13
+ ## Requirements
14
+
15
+ Ruby 3.1+
16
16
 
17
17
  ## Installation
18
18
 
@@ -30,8 +30,130 @@ Or install it yourself as:
30
30
 
31
31
  $ gem install m3u8
32
32
 
33
+ ## CLI
34
+
35
+ The gem includes a command-line tool for inspecting and validating playlists.
36
+
37
+ ### Inspect
38
+
39
+ Display playlist metadata and item summary:
40
+
41
+ ```
42
+ $ m3u8 inspect master.m3u8
43
+ Type: Master
44
+ Independent Segments: Yes
45
+
46
+ Variants: 6
47
+ 1920x1080 5042000 bps hls/1080/1080.m3u8
48
+ 640x360 861000 bps hls/360/360.m3u8
49
+ Media: 2
50
+ Session Keys: 1
51
+ Session Data: 0
52
+
53
+ $ m3u8 inspect media.m3u8
54
+ Type: Media
55
+ Version: 4
56
+ Sequence: 1
57
+ Target: 12
58
+ Duration: 1371.99s
59
+ Playlist: VOD
60
+ Cache: No
61
+
62
+ Segments: 138
63
+ Keys: 0
64
+ Maps: 0
65
+ ```
66
+
67
+ Reads from stdin when no file is given:
68
+
69
+ ```
70
+ $ cat playlist.m3u8 | m3u8 inspect
71
+ ```
72
+
73
+ ### Validate
74
+
75
+ Check playlist validity (exit 0 for valid, 1 for invalid):
76
+
77
+ ```
78
+ $ m3u8 validate playlist.m3u8
79
+ Valid
80
+
81
+ $ m3u8 validate bad.m3u8
82
+ Invalid
83
+ - Playlist contains both master and media items
84
+ ```
85
+
86
+ ## Usage (Builder DSL)
87
+
88
+ `Playlist.build` provides a block-based DSL for concise playlist construction. It supports two forms:
89
+
90
+ ```ruby
91
+ # instance_eval form (clean DSL)
92
+ playlist = M3u8::Playlist.build(version: 4, target: 12) do
93
+ segment duration: 11.34, segment: '1080-7mbps00000.ts'
94
+ segment duration: 11.26, segment: '1080-7mbps00001.ts'
95
+ end
96
+
97
+ # yielded builder form (access outer scope)
98
+ playlist = M3u8::Playlist.build(version: 4) do |b|
99
+ files.each { |f| b.segment duration: 10.0, segment: f }
100
+ end
101
+ ```
102
+
103
+ Build a master playlist:
104
+
105
+ ```ruby
106
+ playlist = M3u8::Playlist.build(independent_segments: true) do
107
+ media type: 'AUDIO', group_id: 'audio', name: 'English',
108
+ default: true, uri: 'eng/index.m3u8'
109
+ playlist bandwidth: 5_042_000, width: 1920, height: 1080,
110
+ profile: 'high', level: 4.1, audio_codec: 'aac-lc',
111
+ uri: 'hls/1080.m3u8'
112
+ playlist bandwidth: 2_387_000, width: 1280, height: 720,
113
+ profile: 'main', level: 3.1, audio_codec: 'aac-lc',
114
+ uri: 'hls/720.m3u8'
115
+ end
116
+ ```
117
+
118
+ Build a media playlist:
119
+
120
+ ```ruby
121
+ playlist = M3u8::Playlist.build(version: 4, target: 12,
122
+ sequence: 1, type: 'VOD') do
123
+ key method: 'AES-128', uri: 'https://example.com/key.bin'
124
+ map uri: 'init.mp4'
125
+ segment duration: 11.34, segment: '00000.ts'
126
+ discontinuity
127
+ segment duration: 11.26, segment: '00001.ts'
128
+ end
129
+ ```
130
+
131
+ Build an LL-HLS playlist:
132
+
133
+ ```ruby
134
+ sc = M3u8::ServerControlItem.new(
135
+ can_skip_until: 24.0, part_hold_back: 1.0,
136
+ can_block_reload: true
137
+ )
138
+ pi = M3u8::PartInfItem.new(part_target: 0.5)
139
+
140
+ playlist = M3u8::Playlist.build(
141
+ version: 9, target: 4, sequence: 100,
142
+ server_control: sc, part_inf: pi, live: true
143
+ ) do
144
+ map uri: 'init.mp4'
145
+ segment duration: 4.0, segment: 'seg100.mp4'
146
+ part duration: 0.5, uri: 'seg101.0.mp4', independent: true
147
+ preload_hint type: 'PART', uri: 'seg101.1.mp4'
148
+ rendition_report uri: '../alt/index.m3u8',
149
+ last_msn: 101, last_part: 0
150
+ end
151
+ ```
152
+
153
+ All DSL methods correspond to item classes: `segment`, `playlist`, `media`, `session_data`, `session_key`, `content_steering`, `key`, `map`, `date_range`, `discontinuity`, `gap`, `time`, `bitrate`, `part`, `preload_hint`, `rendition_report`, `skip`, `define`, `playback_start`.
154
+
33
155
  ## Usage (creating playlists)
34
-
156
+
35
157
  Create a master playlist and add child playlists for adaptive bitrate streaming:
36
158
 
37
159
  ```ruby
@@ -46,8 +168,8 @@ options = { width: 1920, height: 1080, profile: 'high', level: 4.1,
46
168
  audio_codec: 'aac-lc', bandwidth: 540, uri: 'test.url' }
47
169
  item = M3u8::PlaylistItem.new(options)
48
170
  playlist.items << item
49
- ```
50
-
171
+ ```
172
+
51
173
  Add alternate audio, camera angles, closed captions and subtitles by creating MediaItem instances and adding them to the Playlist:
52
174
 
53
175
  ```ruby
@@ -57,8 +179,44 @@ hash = { type: 'AUDIO', group_id: 'audio-lo', language: 'fre',
57
179
  item = M3u8::MediaItem.new(hash)
58
180
  playlist.items << item
59
181
  ```
60
-
61
- Create a standard playlist and add MPEG-TS segments via SegmentItem. You can also specify options for this type of playlist, however these options are ignored if playlist becomes a master playlist (anything but segments added):
182
+
183
+ Add Content Steering for dynamic CDN pathway selection:
184
+
185
+ ```ruby
186
+ item = M3u8::ContentSteeringItem.new(
187
+ server_uri: 'https://example.com/steering',
188
+ pathway_id: 'CDN-A'
189
+ )
190
+ playlist.items << item
191
+ ```
192
+
193
+ Add variable definitions:
194
+
195
+ ```ruby
196
+ item = M3u8::DefineItem.new(name: 'base', value: 'https://example.com')
197
+ playlist.items << item
198
+ ```
199
+
200
+ Add a session-level encryption key (master playlists):
201
+
202
+ ```ruby
203
+ item = M3u8::SessionKeyItem.new(
204
+ method: 'AES-128', uri: 'https://example.com/key.bin'
205
+ )
206
+ playlist.items << item
207
+ ```
208
+
209
+ Add session-level data (master playlists):
210
+
211
+ ```ruby
212
+ item = M3u8::SessionDataItem.new(
213
+ data_id: 'com.example.title', value: 'My Video',
214
+ language: 'en'
215
+ )
216
+ playlist.items << item
217
+ ```
218
+
219
+ Create a standard playlist and add MPEG-TS segments via SegmentItem:
62
220
 
63
221
  ```ruby
64
222
  options = { version: 1, cache: false, target: 12, sequence: 1 }
@@ -67,7 +225,132 @@ playlist = M3u8::Playlist.new(options)
67
225
  item = M3u8::SegmentItem.new(duration: 11, segment: 'test.ts')
68
226
  playlist.items << item
69
227
  ```
70
-
228
+
229
+ ### Media segment tags
230
+
231
+ Add an encryption key for subsequent segments:
232
+
233
+ ```ruby
234
+ item = M3u8::KeyItem.new(
235
+ method: 'AES-128',
236
+ uri: 'https://example.com/key.bin',
237
+ iv: '0x1234567890abcdef1234567890abcdef'
238
+ )
239
+ playlist.items << item
240
+ ```
241
+
242
+ Specify an initialization segment (e.g. fMP4 header):
243
+
244
+ ```ruby
245
+ item = M3u8::MapItem.new(
246
+ uri: 'init.mp4', byterange: { length: 812, start: 0 }
247
+ )
248
+ playlist.items << item
249
+ ```
250
+
251
+ Insert a timed metadata date range:
252
+
253
+ ```ruby
254
+ item = M3u8::DateRangeItem.new(
255
+ id: 'ad-break-1', start_date: '2024-06-01T12:00:00Z',
256
+ planned_duration: 30.0, cue: 'PRE',
257
+ client_attributes: { 'X-AD-ID' => '"foo"' }
258
+ )
259
+ playlist.items << item
260
+ ```
261
+
262
+ #### HLS Interstitials
263
+
264
+ `DateRangeItem` supports [HLS Interstitials](https://developer.apple.com/documentation/http-live-streaming/providing-an-hls-interstitial) attributes as first-class accessors for ad insertion, pre/post-rolls, and timeline integration:
265
+
266
+ ```ruby
267
+ item = M3u8::DateRangeItem.new(
268
+ id: 'ad-break-1',
269
+ class_name: 'com.apple.hls.interstitial',
270
+ start_date: '2024-06-01T12:00:00Z',
271
+ asset_uri: 'http://example.com/ad.m3u8',
272
+ resume_offset: 0.0,
273
+ playout_limit: 30.0,
274
+ restrict: 'SKIP,JUMP',
275
+ snap: 'OUT',
276
+ content_may_vary: 'YES'
277
+ )
278
+ playlist.items << item
279
+ ```
280
+
281
+ | HLS Attribute | Accessor | Type |
282
+ |----------------------|---------------------|--------|
283
+ | X-ASSET-URI | `asset_uri` | String |
284
+ | X-ASSET-LIST | `asset_list` | String |
285
+ | X-RESUME-OFFSET | `resume_offset` | Float |
286
+ | X-PLAYOUT-LIMIT | `playout_limit` | Float |
287
+ | X-RESTRICT | `restrict` | String |
288
+ | X-SNAP | `snap` | String |
289
+ | X-TIMELINE-OCCUPIES | `timeline_occupies` | String |
290
+ | X-TIMELINE-STYLE | `timeline_style` | String |
291
+ | X-CONTENT-MAY-VARY | `content_may_vary` | String |
292
+
293
+ Signal an encoding discontinuity:
294
+
295
+ ```ruby
296
+ playlist.items << M3u8::DiscontinuityItem.new
297
+ ```
298
+
299
+ Attach a program date/time to the next segment:
300
+
301
+ ```ruby
302
+ item = M3u8::TimeItem.new(time: Time.iso8601('2024-06-01T12:00:00Z'))
303
+ playlist.items << item
304
+ ```
305
+
306
+ Mark a gap in segment availability:
307
+
308
+ ```ruby
309
+ playlist.items << M3u8::GapItem.new
310
+ ```
311
+
312
+ Add a bitrate hint for upcoming segments:
313
+
314
+ ```ruby
315
+ item = M3u8::BitrateItem.new(bitrate: 1500)
316
+ playlist.items << item
317
+ ```
318
+
319
+ ### Low-Latency HLS
320
+
321
+ Create an LL-HLS playlist with server control, partial segments, and preload hints:
322
+
323
+ ```ruby
324
+ server_control = M3u8::ServerControlItem.new(
325
+ can_skip_until: 24.0, part_hold_back: 1.0,
326
+ can_block_reload: true
327
+ )
328
+ part_inf = M3u8::PartInfItem.new(part_target: 0.5)
329
+ playlist = M3u8::Playlist.new(
330
+ version: 9, target: 4, sequence: 100,
331
+ server_control: server_control, part_inf: part_inf,
332
+ live: true
333
+ )
334
+
335
+ item = M3u8::SegmentItem.new(duration: 4.0, segment: 'seg100.mp4')
336
+ playlist.items << item
337
+
338
+ part = M3u8::PartItem.new(
339
+ duration: 0.5, uri: 'seg101.0.mp4', independent: true
340
+ )
341
+ playlist.items << part
342
+
343
+ hint = M3u8::PreloadHintItem.new(type: 'PART', uri: 'seg101.1.mp4')
344
+ playlist.items << hint
345
+
346
+ report = M3u8::RenditionReportItem.new(
347
+ uri: '../alt/index.m3u8', last_msn: 101, last_part: 0
348
+ )
349
+ playlist.items << report
350
+ ```
351
+
352
+ ### Writing playlists
353
+
71
354
  You can pass an IO object to the write method:
72
355
 
73
356
  ```ruby
@@ -80,7 +363,7 @@ You can also access the playlist as a string:
80
363
 
81
364
  ```ruby
82
365
  playlist.to_s
83
- ```
366
+ ```
84
367
 
85
368
  M3u8::Writer is the class that handles generating the playlist output.
86
369
 
@@ -92,6 +375,117 @@ options = { width: 1920, height: 1080, codecs: 'avc1.66.30,mp4a.40.2',
92
375
  item = M3u8::PlaylistItem.new(options)
93
376
  ```
94
377
 
378
+ ## Frozen playlists
379
+
380
+ 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:
381
+
382
+ ```ruby
383
+ playlist = M3u8::Playlist.read(File.open('master.m3u8'))
384
+ playlist.frozen? # => true
385
+ playlist.items.frozen? # => true
386
+ playlist.items.first.frozen? # => true
387
+ ```
388
+
389
+ Playlists created with `Playlist.new` remain mutable. Call `freeze` explicitly when ready:
390
+
391
+ ```ruby
392
+ playlist = M3u8::Playlist.new
393
+ playlist.items << M3u8::SegmentItem.new(duration: 10.0, segment: 'test.ts')
394
+ playlist.freeze
395
+ ```
396
+
397
+ Frozen playlists still support `to_s` and `write` for output.
398
+
399
+ ## SCTE-35 parsing
400
+
401
+ `DateRangeItem` stores SCTE-35 values (`scte35_cmd`, `scte35_out`, `scte35_in`) as raw hex strings. Convenience methods parse them into structured objects:
402
+
403
+ ```ruby
404
+ playlist = M3u8::Playlist.read(file)
405
+ date_range = playlist.date_ranges.first
406
+
407
+ info = date_range.scte35_out_info
408
+ info.table_id # => 252 (0xFC)
409
+ info.pts_adjustment # => 0
410
+ info.tier # => 4095
411
+ info.splice_command_type # => 5
412
+
413
+ cmd = info.splice_command # => Scte35SpliceInsert
414
+ cmd.splice_event_id # => 1
415
+ cmd.out_of_network_indicator # => true
416
+ cmd.pts_time # => 90000
417
+ cmd.break_duration # => 2700000
418
+ cmd.break_auto_return # => true
419
+ ```
420
+
421
+ Parse any SCTE-35 hex string directly:
422
+
423
+ ```ruby
424
+ info = M3u8::Scte35.parse('0xFC301100...')
425
+ info.to_s # => original hex string
426
+ ```
427
+
428
+ ### Command types
429
+
430
+ | Type | Class | Key attributes |
431
+ |------|-------|----------------|
432
+ | 0x00 | `Scte35SpliceNull` | *(none)* |
433
+ | 0x05 | `Scte35SpliceInsert` | `splice_event_id`, `out_of_network_indicator`, `pts_time`, `break_duration`, `break_auto_return`, `unique_program_id`, `avail_num`, `avails_expected` |
434
+ | 0x06 | `Scte35TimeSignal` | `pts_time` |
435
+
436
+ Unknown command types store raw bytes in `splice_command`.
437
+
438
+ ### Descriptors
439
+
440
+ Segmentation descriptors (tag 0x02, identifier `CUEI`) are parsed as `Scte35SegmentationDescriptor`:
441
+
442
+ ```ruby
443
+ desc = info.descriptors.first
444
+ desc.segmentation_event_id # => 1
445
+ desc.segmentation_type_id # => 0x30
446
+ desc.segmentation_duration # => 2700000
447
+ desc.segmentation_upid_type # => 9
448
+ desc.segmentation_upid # => "SIGNAL123"
449
+ desc.segment_num # => 0
450
+ desc.segments_expected # => 0
451
+ ```
452
+
453
+ Unknown descriptor tags store raw bytes.
454
+
455
+ ## Validation
456
+
457
+ Check whether a playlist is valid and inspect specific errors:
458
+
459
+ ```ruby
460
+ playlist.valid?
461
+ # => true
462
+
463
+ playlist.errors
464
+ # => []
465
+ ```
466
+
467
+ When a playlist has issues, `errors` returns descriptive messages:
468
+
469
+ ```ruby
470
+ playlist.valid?
471
+ # => false
472
+
473
+ playlist.errors
474
+ # => ["Playlist contains both master and media items"]
475
+ ```
476
+
477
+ The following validations are performed:
478
+
479
+ * Mixed item types (both master and media items in one playlist)
480
+ * Target duration less than any segment's rounded duration
481
+ * Segment items missing a URI or having a negative duration
482
+ * Playlist items missing a URI or valid bandwidth
483
+ * Media items missing type, group ID, or name
484
+ * Key and session key items missing a URI when method is not NONE
485
+ * Session data items missing data ID, or having both/neither value and URI
486
+ * Part items missing a URI or duration
487
+
488
+ `valid?` delegates to `errors.empty?` and both are recomputed on each call.
95
489
 
96
490
  ## Usage (parsing playlists)
97
491
 
@@ -102,46 +496,137 @@ playlist.master?
102
496
  # => true
103
497
  ```
104
498
 
105
- Access items in playlist:
499
+ Query playlist properties:
500
+
501
+ ```ruby
502
+ playlist.master?
503
+ # => true (contains variant streams)
504
+ playlist.live?
505
+ # => false (master playlists are never live)
506
+ ```
507
+
508
+ For media playlists, `duration` returns total segment duration:
509
+
510
+ ```ruby
511
+ media = M3u8::Playlist.read(
512
+ File.open('spec/fixtures/event_playlist.m3u8')
513
+ )
514
+ media.live?
515
+ # => false
516
+ media.duration
517
+ # => 17.0 (sum of all segment durations)
518
+ ```
519
+
520
+ Access items and their attributes:
106
521
 
107
522
  ```ruby
108
523
  playlist.items.first
109
- # => #<M3u8::PlaylistItem:0x007fa569bc7698 @program_id="1", @resolution="1920x1080",
110
- # @codecs="avc1.640028,mp4a.40.2", @bandwidth="5042000",
111
- # @playlist="hls/1080-7mbps/1080-7mbps.m3u8">
524
+ # => #<M3u8::PlaylistItem ...>
525
+
526
+ media.segments.first.duration
527
+ # => 6.0
528
+ media.segments.first.segment
529
+ # => "segment0.mp4"
112
530
  ```
113
531
 
114
- Create a new playlist item with options:
532
+ Convenience methods filter items by type:
115
533
 
116
534
  ```ruby
117
- options = { width: 1920, height: 1080, profile: 'high', level: 4.1,
118
- audio_codec: 'aac-lc', bandwidth: 540, uri: 'test.url' }
119
- item = M3u8::PlaylistItem.new(options)
120
- #add it to the top of the playlist
121
- playlist.items.unshift(item)
535
+ playlist.playlists # => [PlaylistItem, ...]
536
+ playlist.segments # => [SegmentItem, ...]
537
+ playlist.media_items # => [MediaItem, ...]
538
+ playlist.keys # => [KeyItem, ...]
539
+ playlist.maps # => [MapItem, ...]
540
+ playlist.date_ranges # => [DateRangeItem, ...]
541
+ playlist.parts # => [PartItem, ...]
542
+ playlist.session_data # => [SessionDataItem, ...]
122
543
  ```
123
544
 
124
- M3u8::Reader is the class handles parsing if you want more control over the process.
545
+ Parse an LL-HLS playlist:
125
546
 
126
- ## Usage (misc)
127
- Generate the codec string based on audio and video codec options without dealing a playlist instance:
547
+ ```ruby
548
+ file = File.open 'spec/fixtures/ll_hls_playlist.m3u8'
549
+ playlist = M3u8::Playlist.read(file)
550
+ playlist.server_control.can_block_reload
551
+ # => true
552
+ playlist.part_inf.part_target
553
+ # => 0.5
554
+ ```
555
+
556
+ M3u8::Reader is the class that handles parsing if you want more control over the process.
557
+
558
+ ## Codec string generation
559
+
560
+ Generate the codec string based on audio and video codec options without dealing with a playlist instance:
128
561
 
129
562
  ```ruby
130
563
  options = { profile: 'baseline', level: 3.0, audio_codec: 'aac-lc' }
131
564
  codecs = M3u8::Playlist.codecs(options)
132
565
  # => "avc1.66.30,mp4a.40.2"
133
- ```
134
-
135
- * Values for audio_codec (codec name): aac-lc, he-aac, mp3
136
- * Values for profile (H.264 Profile): baseline, main, high.
137
- * Values for level (H.264 Level): 3.0, 3.1, 4.0, 4.1.
138
-
139
- Not all Levels and Profiles can be combined and validation is not currently implemented, consult H.264 documentation for further details.
140
-
566
+ ```
141
567
 
142
- ## Roadmap
143
- * Implement validation of all tags, attributes, and values per HLS I-D.
144
- * Perhaps support for different versions of HLS I-D, defaulting to latest.
568
+ ### Video codecs
569
+
570
+ | Profile | Description |
571
+ |---------|-------------|
572
+ | `baseline`, `main`, `high` | H.264/AVC |
573
+ | `hevc-main`, `hevc-main-10` | HEVC/H.265 |
574
+ | `av1-main`, `av1-high` | AV1 |
575
+
576
+ ### Audio codecs
577
+
578
+ | Value | Codec |
579
+ |-------|-------|
580
+ | `aac-lc` | AAC-LC |
581
+ | `he-aac` | HE-AAC |
582
+ | `mp3` | MP3 |
583
+ | `ac-3` | AC-3 (Dolby Digital) |
584
+ | `ec-3`, `e-ac-3` | E-AC-3 (Dolby Digital Plus) |
585
+ | `flac` | FLAC |
586
+ | `opus` | Opus |
587
+
588
+ ## Supported tags
589
+
590
+ ### Master playlist tags
591
+ * `EXT-X-STREAM-INF` / `EXT-X-I-FRAME-STREAM-INF` — including `STABLE-VARIANT-ID`, `VIDEO-RANGE`, `ALLOWED-CPC`, `PATHWAY-ID`, `REQ-VIDEO-LAYOUT`, `SUPPLEMENTAL-CODECS`, `SCORE`
592
+ * `EXT-X-MEDIA` — including `STABLE-RENDITION-ID`, `BIT-DEPTH`, `SAMPLE-RATE`
593
+ * `EXT-X-SESSION-DATA`
594
+ * `EXT-X-SESSION-KEY`
595
+ * `EXT-X-CONTENT-STEERING`
596
+
597
+ ### Media playlist tags
598
+ * `EXT-X-TARGETDURATION`
599
+ * `EXT-X-MEDIA-SEQUENCE`
600
+ * `EXT-X-DISCONTINUITY-SEQUENCE`
601
+ * `EXT-X-PLAYLIST-TYPE`
602
+ * `EXT-X-I-FRAMES-ONLY`
603
+ * `EXT-X-ALLOW-CACHE`
604
+ * `EXT-X-ENDLIST`
605
+
606
+ ### Media segment tags
607
+ * `EXTINF`
608
+ * `EXT-X-BYTERANGE`
609
+ * `EXT-X-DISCONTINUITY`
610
+ * `EXT-X-KEY`
611
+ * `EXT-X-MAP`
612
+ * `EXT-X-PROGRAM-DATE-TIME`
613
+ * `EXT-X-DATERANGE`
614
+ * `EXT-X-GAP`
615
+ * `EXT-X-BITRATE`
616
+
617
+ ### Universal tags
618
+ * `EXT-X-INDEPENDENT-SEGMENTS`
619
+ * `EXT-X-START`
620
+ * `EXT-X-DEFINE`
621
+ * `EXT-X-VERSION`
622
+
623
+ ### Low-Latency HLS tags
624
+ * `EXT-X-SERVER-CONTROL`
625
+ * `EXT-X-PART-INF`
626
+ * `EXT-X-PART`
627
+ * `EXT-X-SKIP`
628
+ * `EXT-X-PRELOAD-HINT`
629
+ * `EXT-X-RENDITION-REPORT`
145
630
 
146
631
  ## Contributing
147
632
 
@@ -152,6 +637,5 @@ Not all Levels and Profiles can be combined and validation is not currently impl
152
637
  5. Push to the branch (`git push origin my-new-feature`)
153
638
  6. Create a new Pull Request
154
639
 
155
-
156
640
  ## License
157
641
  MIT License - See [LICENSE.txt](https://github.com/sethdeckard/m3u8/blob/master/LICENSE.txt) for details.
data/Rakefile CHANGED
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require 'bundler/gem_tasks'
3
4
  require 'rspec/core/rake_task'
4
5
 
data/bin/m3u8 ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'm3u8'
5
+
6
+ exit M3u8::CLI.run(ARGV, $stdin, $stdout, $stderr)
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+
5
+ module M3u8
6
+ # Shared helpers for formatting HLS tag attributes
7
+ module AttributeFormatter
8
+ # Format a quoted attribute (e.g. KEY="value").
9
+ # @param key [String] attribute name
10
+ # @param value [Object, nil] attribute value
11
+ # @return [String, nil] formatted string or nil when value is nil
12
+ def quoted_format(key, value)
13
+ %(#{key}="#{value}") unless value.nil?
14
+ end
15
+
16
+ # Format an unquoted attribute (e.g. KEY=value).
17
+ # @param key [String] attribute name
18
+ # @param value [Object, nil] attribute value
19
+ # @return [String, nil] formatted string or nil when value is nil
20
+ def unquoted_format(key, value)
21
+ "#{key}=#{value}" unless value.nil?
22
+ end
23
+
24
+ # Format a YES/NO boolean attribute (e.g. KEY=YES).
25
+ # @param key [String] attribute name
26
+ # @param value [Boolean, nil] attribute value
27
+ # @return [String, nil] formatted string or nil when value is nil
28
+ def boolean_format(key, value)
29
+ "#{key}=#{value == true ? 'YES' : 'NO'}" unless value.nil?
30
+ end
31
+
32
+ # Format a decimal attribute, ensuring it formatted as a floating-point
33
+ # number or integer
34
+ # @param number [Float, Integer, nil] the number to format
35
+ # @return [String, nil] formatted string or nil when value is nil
36
+ def decimal_format(number)
37
+ case number
38
+ when nil
39
+ nil
40
+ when Float
41
+ BigDecimal(number).to_s('F')
42
+ else
43
+ number.to_s
44
+ end
45
+ end
46
+ end
47
+ end