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.
- checksums.yaml +5 -5
- data/.github/workflows/ci.yml +23 -0
- data/.gitignore +1 -1
- data/.rubocop.yml +31 -0
- data/CHANGELOG.md +107 -0
- data/Gemfile +7 -0
- data/LICENSE.txt +1 -1
- data/README.md +524 -40
- data/Rakefile +1 -0
- data/bin/m3u8 +6 -0
- data/lib/m3u8/attribute_formatter.rb +47 -0
- data/lib/m3u8/bitrate_item.rb +31 -0
- data/lib/m3u8/builder.rb +48 -0
- data/lib/m3u8/byte_range.rb +10 -0
- data/lib/m3u8/cli/inspect_command.rb +97 -0
- data/lib/m3u8/cli/validate_command.rb +24 -0
- data/lib/m3u8/cli.rb +116 -0
- data/lib/m3u8/codecs.rb +89 -0
- data/lib/m3u8/content_steering_item.rb +45 -0
- data/lib/m3u8/date_range_item.rb +135 -64
- data/lib/m3u8/define_item.rb +54 -0
- data/lib/m3u8/discontinuity_item.rb +3 -0
- data/lib/m3u8/encryptable.rb +27 -30
- data/lib/m3u8/error.rb +1 -0
- data/lib/m3u8/gap_item.rb +14 -0
- data/lib/m3u8/key_item.rb +7 -0
- data/lib/m3u8/map_item.rb +16 -5
- data/lib/m3u8/media_item.rb +48 -76
- data/lib/m3u8/part_inf_item.rb +35 -0
- data/lib/m3u8/part_item.rb +67 -0
- data/lib/m3u8/playback_start.rb +19 -12
- data/lib/m3u8/playlist.rb +221 -13
- data/lib/m3u8/playlist_item.rb +128 -124
- data/lib/m3u8/preload_hint_item.rb +54 -0
- data/lib/m3u8/reader.rb +86 -28
- data/lib/m3u8/rendition_report_item.rb +48 -0
- data/lib/m3u8/scte35.rb +130 -0
- data/lib/m3u8/scte35_bit_reader.rb +51 -0
- data/lib/m3u8/scte35_segmentation_descriptor.rb +54 -0
- data/lib/m3u8/scte35_splice_insert.rb +62 -0
- data/lib/m3u8/scte35_splice_null.rb +8 -0
- data/lib/m3u8/scte35_time_signal.rb +19 -0
- data/lib/m3u8/segment_item.rb +37 -3
- data/lib/m3u8/server_control_item.rb +69 -0
- data/lib/m3u8/session_data_item.rb +17 -28
- data/lib/m3u8/session_key_item.rb +8 -1
- data/lib/m3u8/skip_item.rb +48 -0
- data/lib/m3u8/time_item.rb +10 -0
- data/lib/m3u8/version.rb +1 -1
- data/lib/m3u8/writer.rb +24 -1
- data/lib/m3u8.rb +30 -6
- data/m3u8.gemspec +12 -12
- data/spec/fixtures/content_steering.m3u8 +10 -0
- data/spec/fixtures/daterange_playlist.m3u8 +14 -0
- data/spec/fixtures/encrypted_discontinuity.m3u8 +17 -0
- data/spec/fixtures/event_playlist.m3u8 +18 -0
- data/spec/fixtures/gap_playlist.m3u8 +14 -0
- data/spec/fixtures/ll_hls_advanced.m3u8 +18 -0
- data/spec/fixtures/ll_hls_playlist.m3u8 +20 -0
- data/spec/fixtures/master_full.m3u8 +14 -0
- data/spec/fixtures/master_v13.m3u8 +8 -0
- data/spec/lib/m3u8/bitrate_item_spec.rb +26 -0
- data/spec/lib/m3u8/builder_spec.rb +352 -0
- data/spec/lib/m3u8/byte_range_spec.rb +1 -0
- data/spec/lib/m3u8/cli/inspect_command_spec.rb +102 -0
- data/spec/lib/m3u8/cli/validate_command_spec.rb +39 -0
- data/spec/lib/m3u8/cli_spec.rb +104 -0
- data/spec/lib/m3u8/content_steering_item_spec.rb +56 -0
- data/spec/lib/m3u8/date_range_item_spec.rb +159 -31
- data/spec/lib/m3u8/define_item_spec.rb +59 -0
- data/spec/lib/m3u8/discontinuity_item_spec.rb +1 -0
- data/spec/lib/m3u8/gap_item_spec.rb +12 -0
- data/spec/lib/m3u8/key_item_spec.rb +1 -0
- data/spec/lib/m3u8/map_item_spec.rb +1 -0
- data/spec/lib/m3u8/media_item_spec.rb +34 -0
- data/spec/lib/m3u8/part_inf_item_spec.rb +27 -0
- data/spec/lib/m3u8/part_item_spec.rb +67 -0
- data/spec/lib/m3u8/playback_start_spec.rb +4 -5
- data/spec/lib/m3u8/playlist_item_spec.rb +130 -17
- data/spec/lib/m3u8/playlist_spec.rb +545 -13
- data/spec/lib/m3u8/preload_hint_item_spec.rb +57 -0
- data/spec/lib/m3u8/reader_spec.rb +376 -29
- data/spec/lib/m3u8/rendition_report_item_spec.rb +56 -0
- data/spec/lib/m3u8/round_trip_spec.rb +152 -0
- data/spec/lib/m3u8/scte35_bit_reader_spec.rb +106 -0
- data/spec/lib/m3u8/scte35_segmentation_descriptor_spec.rb +143 -0
- data/spec/lib/m3u8/scte35_spec.rb +94 -0
- data/spec/lib/m3u8/scte35_splice_insert_spec.rb +185 -0
- data/spec/lib/m3u8/scte35_splice_null_spec.rb +12 -0
- data/spec/lib/m3u8/scte35_time_signal_spec.rb +50 -0
- data/spec/lib/m3u8/segment_item_spec.rb +47 -0
- data/spec/lib/m3u8/server_control_item_spec.rb +64 -0
- data/spec/lib/m3u8/session_data_item_spec.rb +1 -0
- data/spec/lib/m3u8/session_key_item_spec.rb +1 -0
- data/spec/lib/m3u8/skip_item_spec.rb +48 -0
- data/spec/lib/m3u8/time_item_spec.rb +1 -0
- data/spec/lib/m3u8/writer_spec.rb +69 -30
- data/spec/lib/m3u8_spec.rb +1 -0
- data/spec/spec_helper.rb +4 -87
- metadata +70 -129
- data/.hound.yml +0 -3
- data/.travis.yml +0 -8
- data/Guardfile +0 -6
data/README.md
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
[](http://badge.fury.io/rb/m3u8)
|
|
2
|
-
[](https://coveralls.io/github/sethdeckard/m3u8?branch=master)
|
|
4
|
-
[](https://codeclimate.com/github/sethdeckard/m3u8)
|
|
5
|
-
[](https://gemnasium.com/sethdeckard/m3u8)
|
|
6
|
-
[](https://hakiri.io/github/sethdeckard/m3u8/master)
|
|
2
|
+
[](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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
532
|
+
Convenience methods filter items by type:
|
|
115
533
|
|
|
116
534
|
```ruby
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
#
|
|
121
|
-
playlist.
|
|
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
|
-
|
|
545
|
+
Parse an LL-HLS playlist:
|
|
125
546
|
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
data/bin/m3u8
ADDED
|
@@ -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
|