m3u8 1.0.0 → 1.2.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/AGENTS.md +7 -4
- data/CHANGELOG.md +12 -0
- data/CLAUDE.md +1 -1
- data/README.md +190 -3
- data/lib/m3u8/builder.rb +47 -0
- data/lib/m3u8/playlist.rb +46 -7
- data/lib/m3u8/version.rb +1 -1
- data/spec/lib/m3u8/builder_spec.rb +338 -0
- data/spec/lib/m3u8/playlist_spec.rb +74 -0
- metadata +8 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7a9a7d6e157afd5fd7028323544bf9d851bd898890f90c66af6f64e672d7e456
|
|
4
|
+
data.tar.gz: ee452e25dfb96ee9634e9a00c287a87ac4640dceb2d965550e31199e0c0b4e26
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f6b458aaf9a1a1060d19dca456020b54813f0c1f011615593e088a512a87ed32120cd5d509fb66dd3b12f85fe11a255304eede7852a00197cfd9513c918a33eb
|
|
7
|
+
data.tar.gz: 7c543fd8e6004adaf275adfe95c231b42fe0549805436f8dea3e77f2731f273084c99887781d0b69daf05ec1e53218fb8711be895fc0c14e9f0687f983a611b1
|
data/AGENTS.md
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
## Development Workflow
|
|
4
4
|
|
|
5
|
-
-
|
|
6
|
-
-
|
|
5
|
+
- Git workflow: GitHub flow
|
|
6
|
+
- Small (but logical) commits that can each be deployed independently
|
|
7
|
+
- Each commit must not break CI
|
|
7
8
|
- Prefer incremental changes over large feature branches
|
|
8
9
|
|
|
9
10
|
### Commit Messages
|
|
@@ -12,13 +13,15 @@
|
|
|
12
13
|
|
|
13
14
|
**Body:** Wrap at 72 chars, explain what/why not how, blank line after subject
|
|
14
15
|
|
|
15
|
-
**Leading verbs:** Add, Remove, Fix, Upgrade, Refactor, Reformat,
|
|
16
|
+
**Leading verbs:** Add, Remove, Fix, Upgrade, Refactor, Reformat, Document, Reword
|
|
16
17
|
|
|
17
18
|
## Development Standards
|
|
18
19
|
|
|
20
|
+
- README updated with API changes
|
|
19
21
|
- **Tests must cover all behavior** - check with `coverage/index.html` after running specs
|
|
20
22
|
- RuboCop enforces 80-char line limit and other style
|
|
21
23
|
|
|
22
24
|
## Deployment
|
|
23
25
|
|
|
24
|
-
|
|
26
|
+
- Kicking off PR: `ghprcw`
|
|
27
|
+
- Never deploy anything
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
**1.2.0**
|
|
2
|
+
|
|
3
|
+
* Added `Playlist.build` with block-based Builder DSL for concise playlist construction. Supports both `instance_eval` (clean DSL) and yielded builder (outer scope access) forms. All 19 item types have corresponding DSL methods.
|
|
4
|
+
|
|
5
|
+
***
|
|
6
|
+
|
|
7
|
+
**1.1.0**
|
|
8
|
+
|
|
9
|
+
* Added convenience accessor methods to `Playlist` for filtering items by type: `segments`, `playlists`, `media_items`, `keys`, `maps`, `date_ranges`, `parts`, `session_data`.
|
|
10
|
+
|
|
11
|
+
***
|
|
12
|
+
|
|
1
13
|
**1.0.0**
|
|
2
14
|
|
|
3
15
|
* Full HLS spec compliance with draft-pantos-hls-rfc8216bis-19 (Protocol Version 13).
|
data/CLAUDE.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
AGENTS.md
|
|
1
|
+
./AGENTS.md
|
data/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
[](https://github.com/sethdeckard/m3u8/actions/workflows/ci.yml)
|
|
3
3
|
# m3u8
|
|
4
4
|
|
|
5
|
-
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).
|
|
6
6
|
|
|
7
|
-
* Full coverage of [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.
|
|
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.
|
|
8
8
|
* Provides parsing of an m3u8 playlist into an object model from any File, StringIO, or string.
|
|
9
9
|
* Provides ability to write playlist to a File or StringIO or expose as string via to_s.
|
|
10
10
|
* Distinction between a master and media playlist is handled automatically (single Playlist class).
|
|
@@ -30,6 +30,75 @@ Or install it yourself as:
|
|
|
30
30
|
|
|
31
31
|
$ gem install m3u8
|
|
32
32
|
|
|
33
|
+
## Usage (Builder DSL)
|
|
34
|
+
|
|
35
|
+
`Playlist.build` provides a block-based DSL for concise playlist construction. It supports two forms:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
# instance_eval form (clean DSL)
|
|
39
|
+
playlist = M3u8::Playlist.build(version: 4, target: 12) do
|
|
40
|
+
segment duration: 11.34, segment: '1080-7mbps00000.ts'
|
|
41
|
+
segment duration: 11.26, segment: '1080-7mbps00001.ts'
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# yielded builder form (access outer scope)
|
|
45
|
+
playlist = M3u8::Playlist.build(version: 4) do |b|
|
|
46
|
+
files.each { |f| b.segment duration: 10.0, segment: f }
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Build a master playlist:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
playlist = M3u8::Playlist.build(independent_segments: true) do
|
|
54
|
+
media type: 'AUDIO', group_id: 'audio', name: 'English',
|
|
55
|
+
default: true, uri: 'eng/index.m3u8'
|
|
56
|
+
playlist bandwidth: 5_042_000, width: 1920, height: 1080,
|
|
57
|
+
profile: 'high', level: 4.1, audio_codec: 'aac-lc',
|
|
58
|
+
uri: 'hls/1080.m3u8'
|
|
59
|
+
playlist bandwidth: 2_387_000, width: 1280, height: 720,
|
|
60
|
+
profile: 'main', level: 3.1, audio_codec: 'aac-lc',
|
|
61
|
+
uri: 'hls/720.m3u8'
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Build a media playlist:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
playlist = M3u8::Playlist.build(version: 4, target: 12,
|
|
69
|
+
sequence: 1, type: 'VOD') do
|
|
70
|
+
key method: 'AES-128', uri: 'https://example.com/key.bin'
|
|
71
|
+
map uri: 'init.mp4'
|
|
72
|
+
segment duration: 11.34, segment: '00000.ts'
|
|
73
|
+
discontinuity
|
|
74
|
+
segment duration: 11.26, segment: '00001.ts'
|
|
75
|
+
end
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Build an LL-HLS playlist:
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
sc = M3u8::ServerControlItem.new(
|
|
82
|
+
can_skip_until: 24.0, part_hold_back: 1.0,
|
|
83
|
+
can_block_reload: true
|
|
84
|
+
)
|
|
85
|
+
pi = M3u8::PartInfItem.new(part_target: 0.5)
|
|
86
|
+
|
|
87
|
+
playlist = M3u8::Playlist.build(
|
|
88
|
+
version: 9, target: 4, sequence: 100,
|
|
89
|
+
server_control: sc, part_inf: pi, live: true
|
|
90
|
+
) do
|
|
91
|
+
map uri: 'init.mp4'
|
|
92
|
+
segment duration: 4.0, segment: 'seg100.mp4'
|
|
93
|
+
part duration: 0.5, uri: 'seg101.0.mp4', independent: true
|
|
94
|
+
preload_hint type: 'PART', uri: 'seg101.1.mp4'
|
|
95
|
+
rendition_report uri: '../alt/index.m3u8',
|
|
96
|
+
last_msn: 101, last_part: 0
|
|
97
|
+
end
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
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`.
|
|
101
|
+
|
|
33
102
|
## Usage (creating playlists)
|
|
34
103
|
|
|
35
104
|
Create a master playlist and add child playlists for adaptive bitrate streaming:
|
|
@@ -75,6 +144,25 @@ item = M3u8::DefineItem.new(name: 'base', value: 'https://example.com')
|
|
|
75
144
|
playlist.items << item
|
|
76
145
|
```
|
|
77
146
|
|
|
147
|
+
Add a session-level encryption key (master playlists):
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
item = M3u8::SessionKeyItem.new(
|
|
151
|
+
method: 'AES-128', uri: 'https://example.com/key.bin'
|
|
152
|
+
)
|
|
153
|
+
playlist.items << item
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Add session-level data (master playlists):
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
item = M3u8::SessionDataItem.new(
|
|
160
|
+
data_id: 'com.example.title', value: 'My Video',
|
|
161
|
+
language: 'en'
|
|
162
|
+
)
|
|
163
|
+
playlist.items << item
|
|
164
|
+
```
|
|
165
|
+
|
|
78
166
|
Create a standard playlist and add MPEG-TS segments via SegmentItem:
|
|
79
167
|
|
|
80
168
|
```ruby
|
|
@@ -85,6 +173,65 @@ item = M3u8::SegmentItem.new(duration: 11, segment: 'test.ts')
|
|
|
85
173
|
playlist.items << item
|
|
86
174
|
```
|
|
87
175
|
|
|
176
|
+
### Media segment tags
|
|
177
|
+
|
|
178
|
+
Add an encryption key for subsequent segments:
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
item = M3u8::KeyItem.new(
|
|
182
|
+
method: 'AES-128',
|
|
183
|
+
uri: 'https://example.com/key.bin',
|
|
184
|
+
iv: '0x1234567890abcdef1234567890abcdef'
|
|
185
|
+
)
|
|
186
|
+
playlist.items << item
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Specify an initialization segment (e.g. fMP4 header):
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
item = M3u8::MapItem.new(
|
|
193
|
+
uri: 'init.mp4', byterange: { length: 812, start: 0 }
|
|
194
|
+
)
|
|
195
|
+
playlist.items << item
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Insert a timed metadata date range:
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
item = M3u8::DateRangeItem.new(
|
|
202
|
+
id: 'ad-break-1', start_date: '2024-06-01T12:00:00Z',
|
|
203
|
+
planned_duration: 30.0,
|
|
204
|
+
client_attributes: { 'X-AD-ID' => '"foo"' }
|
|
205
|
+
)
|
|
206
|
+
playlist.items << item
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Signal an encoding discontinuity:
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
playlist.items << M3u8::DiscontinuityItem.new
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Attach a program date/time to the next segment:
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
item = M3u8::TimeItem.new(time: Time.iso8601('2024-06-01T12:00:00Z'))
|
|
219
|
+
playlist.items << item
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Mark a gap in segment availability:
|
|
223
|
+
|
|
224
|
+
```ruby
|
|
225
|
+
playlist.items << M3u8::GapItem.new
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Add a bitrate hint for upcoming segments:
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
item = M3u8::BitrateItem.new(bitrate: 1500)
|
|
232
|
+
playlist.items << item
|
|
233
|
+
```
|
|
234
|
+
|
|
88
235
|
### Low-Latency HLS
|
|
89
236
|
|
|
90
237
|
Create an LL-HLS playlist with server control, partial segments, and preload hints:
|
|
@@ -153,11 +300,50 @@ playlist.master?
|
|
|
153
300
|
# => true
|
|
154
301
|
```
|
|
155
302
|
|
|
156
|
-
|
|
303
|
+
Query playlist properties:
|
|
304
|
+
|
|
305
|
+
```ruby
|
|
306
|
+
playlist.master?
|
|
307
|
+
# => true (contains variant streams)
|
|
308
|
+
playlist.live?
|
|
309
|
+
# => false (master playlists are never live)
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
For media playlists, `duration` returns total segment duration:
|
|
313
|
+
|
|
314
|
+
```ruby
|
|
315
|
+
media = M3u8::Playlist.read(
|
|
316
|
+
File.open('spec/fixtures/event_playlist.m3u8')
|
|
317
|
+
)
|
|
318
|
+
media.live?
|
|
319
|
+
# => false
|
|
320
|
+
media.duration
|
|
321
|
+
# => 17.0 (sum of all segment durations)
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
Access items and their attributes:
|
|
157
325
|
|
|
158
326
|
```ruby
|
|
159
327
|
playlist.items.first
|
|
160
328
|
# => #<M3u8::PlaylistItem ...>
|
|
329
|
+
|
|
330
|
+
media.segments.first.duration
|
|
331
|
+
# => 6.0
|
|
332
|
+
media.segments.first.segment
|
|
333
|
+
# => "segment0.mp4"
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Convenience methods filter items by type:
|
|
337
|
+
|
|
338
|
+
```ruby
|
|
339
|
+
playlist.playlists # => [PlaylistItem, ...]
|
|
340
|
+
playlist.segments # => [SegmentItem, ...]
|
|
341
|
+
playlist.media_items # => [MediaItem, ...]
|
|
342
|
+
playlist.keys # => [KeyItem, ...]
|
|
343
|
+
playlist.maps # => [MapItem, ...]
|
|
344
|
+
playlist.date_ranges # => [DateRangeItem, ...]
|
|
345
|
+
playlist.parts # => [PartItem, ...]
|
|
346
|
+
playlist.session_data # => [SessionDataItem, ...]
|
|
161
347
|
```
|
|
162
348
|
|
|
163
349
|
Parse an LL-HLS playlist:
|
|
@@ -219,6 +405,7 @@ codecs = M3u8::Playlist.codecs(options)
|
|
|
219
405
|
* `EXT-X-PLAYLIST-TYPE`
|
|
220
406
|
* `EXT-X-I-FRAMES-ONLY`
|
|
221
407
|
* `EXT-X-ALLOW-CACHE`
|
|
408
|
+
* `EXT-X-ENDLIST`
|
|
222
409
|
|
|
223
410
|
### Media segment tags
|
|
224
411
|
* `EXTINF`
|
data/lib/m3u8/builder.rb
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module M3u8
|
|
4
|
+
# Builder provides a block-based DSL for constructing playlists
|
|
5
|
+
class Builder
|
|
6
|
+
ITEMS = {
|
|
7
|
+
segment: 'SegmentItem',
|
|
8
|
+
playlist: 'PlaylistItem',
|
|
9
|
+
media: 'MediaItem',
|
|
10
|
+
session_data: 'SessionDataItem',
|
|
11
|
+
session_key: 'SessionKeyItem',
|
|
12
|
+
content_steering: 'ContentSteeringItem',
|
|
13
|
+
key: 'KeyItem',
|
|
14
|
+
map: 'MapItem',
|
|
15
|
+
date_range: 'DateRangeItem',
|
|
16
|
+
time: 'TimeItem',
|
|
17
|
+
bitrate: 'BitrateItem',
|
|
18
|
+
part: 'PartItem',
|
|
19
|
+
preload_hint: 'PreloadHintItem',
|
|
20
|
+
rendition_report: 'RenditionReportItem',
|
|
21
|
+
skip: 'SkipItem',
|
|
22
|
+
define: 'DefineItem',
|
|
23
|
+
playback_start: 'PlaybackStart'
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
ZERO_ARG_ITEMS = {
|
|
27
|
+
discontinuity: 'DiscontinuityItem',
|
|
28
|
+
gap: 'GapItem'
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
def initialize(playlist)
|
|
32
|
+
@playlist = playlist
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
ITEMS.each do |method_name, class_name|
|
|
36
|
+
define_method(method_name) do |params = {}|
|
|
37
|
+
@playlist.items << M3u8.const_get(class_name).new(params)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
ZERO_ARG_ITEMS.each do |method_name, class_name|
|
|
42
|
+
define_method(method_name) do
|
|
43
|
+
@playlist.items << M3u8.const_get(class_name).new
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/m3u8/playlist.rb
CHANGED
|
@@ -14,6 +14,17 @@ module M3u8
|
|
|
14
14
|
@items = []
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
def self.build(options = {}, &block)
|
|
18
|
+
playlist = new(options)
|
|
19
|
+
builder = Builder.new(playlist)
|
|
20
|
+
if block.arity == 1
|
|
21
|
+
yield builder
|
|
22
|
+
else
|
|
23
|
+
builder.instance_eval(&block)
|
|
24
|
+
end
|
|
25
|
+
playlist
|
|
26
|
+
end
|
|
27
|
+
|
|
17
28
|
def self.codecs(options = {})
|
|
18
29
|
item = PlaylistItem.new(options)
|
|
19
30
|
item.codecs
|
|
@@ -54,12 +65,40 @@ module M3u8
|
|
|
54
65
|
true
|
|
55
66
|
end
|
|
56
67
|
|
|
68
|
+
def segments
|
|
69
|
+
items.select { |item| item.is_a?(SegmentItem) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def playlists
|
|
73
|
+
items.select { |item| item.is_a?(PlaylistItem) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def media_items
|
|
77
|
+
items.select { |item| item.is_a?(MediaItem) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def keys
|
|
81
|
+
items.select { |item| item.is_a?(KeyItem) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def maps
|
|
85
|
+
items.select { |item| item.is_a?(MapItem) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def date_ranges
|
|
89
|
+
items.select { |item| item.is_a?(DateRangeItem) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def parts
|
|
93
|
+
items.select { |item| item.is_a?(PartItem) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def session_data
|
|
97
|
+
items.select { |item| item.is_a?(SessionDataItem) }
|
|
98
|
+
end
|
|
99
|
+
|
|
57
100
|
def duration
|
|
58
|
-
duration
|
|
59
|
-
items.each do |item|
|
|
60
|
-
duration += item.duration if item.is_a?(M3u8::SegmentItem)
|
|
61
|
-
end
|
|
62
|
-
duration
|
|
101
|
+
segments.sum(&:duration)
|
|
63
102
|
end
|
|
64
103
|
|
|
65
104
|
private
|
|
@@ -92,11 +131,11 @@ module M3u8
|
|
|
92
131
|
end
|
|
93
132
|
|
|
94
133
|
def playlist_size
|
|
95
|
-
|
|
134
|
+
playlists.size
|
|
96
135
|
end
|
|
97
136
|
|
|
98
137
|
def segment_size
|
|
99
|
-
|
|
138
|
+
segments.size
|
|
100
139
|
end
|
|
101
140
|
end
|
|
102
141
|
end
|
data/lib/m3u8/version.rb
CHANGED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe M3u8::Builder do
|
|
6
|
+
shared_examples 'a builder method' do |method, klass, params, checks|
|
|
7
|
+
it "adds a #{klass} to the playlist" do
|
|
8
|
+
pl = M3u8::Playlist.build { send(method, **params) }
|
|
9
|
+
|
|
10
|
+
item = pl.items.first
|
|
11
|
+
expect(item).to be_a(klass)
|
|
12
|
+
checks.each do |attr, value|
|
|
13
|
+
expect(item.public_send(attr)).to eq(value)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
shared_examples 'a zero-arg builder method' do |method, klass|
|
|
19
|
+
it "adds a #{klass} to the playlist" do
|
|
20
|
+
pl = M3u8::Playlist.build { send(method) }
|
|
21
|
+
expect(pl.items.first).to be_a(klass)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
describe '#segment' do
|
|
26
|
+
include_examples 'a builder method',
|
|
27
|
+
:segment, M3u8::SegmentItem,
|
|
28
|
+
{ duration: 11.34,
|
|
29
|
+
segment: '1080-7mbps00000.ts' },
|
|
30
|
+
{ duration: 11.34,
|
|
31
|
+
segment: '1080-7mbps00000.ts' }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
describe '#playlist' do
|
|
35
|
+
include_examples 'a builder method',
|
|
36
|
+
:playlist, M3u8::PlaylistItem,
|
|
37
|
+
{ bandwidth: 540, uri: 'test.url' },
|
|
38
|
+
{ bandwidth: 540, uri: 'test.url' }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
describe '#media' do
|
|
42
|
+
include_examples 'a builder method',
|
|
43
|
+
:media, M3u8::MediaItem,
|
|
44
|
+
{ type: 'AUDIO', group_id: 'audio-lo',
|
|
45
|
+
name: 'English', default: true,
|
|
46
|
+
uri: 'eng/prog_index.m3u8' },
|
|
47
|
+
{ type: 'AUDIO', group_id: 'audio-lo',
|
|
48
|
+
name: 'English' }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
describe '#session_data' do
|
|
52
|
+
include_examples 'a builder method',
|
|
53
|
+
:session_data, M3u8::SessionDataItem,
|
|
54
|
+
{ data_id: 'com.example.title',
|
|
55
|
+
value: 'My Video', language: 'en' },
|
|
56
|
+
{ data_id: 'com.example.title',
|
|
57
|
+
value: 'My Video' }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
describe '#session_key' do
|
|
61
|
+
include_examples 'a builder method',
|
|
62
|
+
:session_key, M3u8::SessionKeyItem,
|
|
63
|
+
{ method: 'AES-128',
|
|
64
|
+
uri: 'https://example.com/key.bin' },
|
|
65
|
+
{ method: 'AES-128' }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
describe '#content_steering' do
|
|
69
|
+
include_examples 'a builder method',
|
|
70
|
+
:content_steering,
|
|
71
|
+
M3u8::ContentSteeringItem,
|
|
72
|
+
{ server_uri: 'https://example.com/s',
|
|
73
|
+
pathway_id: 'CDN-A' },
|
|
74
|
+
{ server_uri: 'https://example.com/s',
|
|
75
|
+
pathway_id: 'CDN-A' }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
describe '#key' do
|
|
79
|
+
include_examples 'a builder method',
|
|
80
|
+
:key, M3u8::KeyItem,
|
|
81
|
+
{ method: 'AES-128',
|
|
82
|
+
uri: 'https://example.com/key.bin' },
|
|
83
|
+
{ method: 'AES-128' }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
describe '#map' do
|
|
87
|
+
it 'adds a MapItem to the playlist' do
|
|
88
|
+
pl = M3u8::Playlist.build do
|
|
89
|
+
map uri: 'init.mp4',
|
|
90
|
+
byterange: { length: 812, start: 0 }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
item = pl.items.first
|
|
94
|
+
expect(item).to be_a(M3u8::MapItem)
|
|
95
|
+
expect(item.uri).to eq('init.mp4')
|
|
96
|
+
expect(item.byterange.length).to eq(812)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
describe '#date_range' do
|
|
101
|
+
include_examples 'a builder method',
|
|
102
|
+
:date_range, M3u8::DateRangeItem,
|
|
103
|
+
{ id: 'ad-break-1',
|
|
104
|
+
start_date: '2024-06-01T12:00:00Z',
|
|
105
|
+
planned_duration: 30.0 },
|
|
106
|
+
{ id: 'ad-break-1',
|
|
107
|
+
planned_duration: 30.0 }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
describe '#discontinuity' do
|
|
111
|
+
include_examples 'a zero-arg builder method',
|
|
112
|
+
:discontinuity, M3u8::DiscontinuityItem
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
describe '#gap' do
|
|
116
|
+
include_examples 'a zero-arg builder method',
|
|
117
|
+
:gap, M3u8::GapItem
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
describe '#time' do
|
|
121
|
+
include_examples 'a builder method',
|
|
122
|
+
:time, M3u8::TimeItem,
|
|
123
|
+
{ time: '2024-06-01T12:00:00Z' },
|
|
124
|
+
{ time: '2024-06-01T12:00:00Z' }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
describe '#bitrate' do
|
|
128
|
+
include_examples 'a builder method',
|
|
129
|
+
:bitrate, M3u8::BitrateItem,
|
|
130
|
+
{ bitrate: 1500 },
|
|
131
|
+
{ bitrate: 1500 }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
describe '#part' do
|
|
135
|
+
include_examples 'a builder method',
|
|
136
|
+
:part, M3u8::PartItem,
|
|
137
|
+
{ duration: 0.5, uri: 'seg101.0.mp4',
|
|
138
|
+
independent: true },
|
|
139
|
+
{ duration: 0.5, uri: 'seg101.0.mp4' }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
describe '#preload_hint' do
|
|
143
|
+
include_examples 'a builder method',
|
|
144
|
+
:preload_hint, M3u8::PreloadHintItem,
|
|
145
|
+
{ type: 'PART', uri: 'seg101.1.mp4' },
|
|
146
|
+
{ type: 'PART', uri: 'seg101.1.mp4' }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
describe '#rendition_report' do
|
|
150
|
+
include_examples 'a builder method',
|
|
151
|
+
:rendition_report,
|
|
152
|
+
M3u8::RenditionReportItem,
|
|
153
|
+
{ uri: '../alt/index.m3u8',
|
|
154
|
+
last_msn: 101, last_part: 0 },
|
|
155
|
+
{ uri: '../alt/index.m3u8',
|
|
156
|
+
last_msn: 101 }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
describe '#skip' do
|
|
160
|
+
include_examples 'a builder method',
|
|
161
|
+
:skip, M3u8::SkipItem,
|
|
162
|
+
{ skipped_segments: 10 },
|
|
163
|
+
{ skipped_segments: 10 }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
describe '#define' do
|
|
167
|
+
include_examples 'a builder method',
|
|
168
|
+
:define, M3u8::DefineItem,
|
|
169
|
+
{ name: 'base',
|
|
170
|
+
value: 'https://example.com' },
|
|
171
|
+
{ name: 'base',
|
|
172
|
+
value: 'https://example.com' }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
describe '#playback_start' do
|
|
176
|
+
include_examples 'a builder method',
|
|
177
|
+
:playback_start, M3u8::PlaybackStart,
|
|
178
|
+
{ time_offset: 10.0, precise: true },
|
|
179
|
+
{ time_offset: 10.0 }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
describe 'Playlist.build' do
|
|
183
|
+
it 'returns a Playlist with options' do
|
|
184
|
+
playlist = M3u8::Playlist.build(version: 4,
|
|
185
|
+
target: 12) do
|
|
186
|
+
segment duration: 11.34, segment: 'test.ts'
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
expect(playlist).to be_a(M3u8::Playlist)
|
|
190
|
+
expect(playlist.version).to eq(4)
|
|
191
|
+
expect(playlist.target).to eq(12)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
it 'supports yielded builder form' do
|
|
195
|
+
files = %w[seg1.ts seg2.ts]
|
|
196
|
+
playlist = M3u8::Playlist.build(version: 4) do |b|
|
|
197
|
+
files.each do |f|
|
|
198
|
+
b.segment duration: 10.0, segment: f
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
expect(playlist.items.size).to eq(2)
|
|
203
|
+
expect(playlist.items[0].segment).to eq('seg1.ts')
|
|
204
|
+
expect(playlist.items[1].segment).to eq('seg2.ts')
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
describe 'integration' do
|
|
209
|
+
it 'produces identical output to imperative API ' \
|
|
210
|
+
'for a master playlist' do
|
|
211
|
+
imperative = M3u8::Playlist.new(
|
|
212
|
+
independent_segments: true
|
|
213
|
+
)
|
|
214
|
+
imperative.items << M3u8::PlaylistItem.new(
|
|
215
|
+
program_id: '1', bandwidth: 6400,
|
|
216
|
+
audio_codec: 'mp3', uri: 'lo/index.m3u8'
|
|
217
|
+
)
|
|
218
|
+
imperative.items << M3u8::PlaylistItem.new(
|
|
219
|
+
program_id: '2', bandwidth: 50_000,
|
|
220
|
+
width: 1920, height: 1080,
|
|
221
|
+
profile: 'high', level: 4.1,
|
|
222
|
+
audio_codec: 'aac-lc', uri: 'hi/index.m3u8'
|
|
223
|
+
)
|
|
224
|
+
imperative.items << M3u8::SessionDataItem.new(
|
|
225
|
+
data_id: 'com.test.title', value: 'Test',
|
|
226
|
+
language: 'en'
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
built = M3u8::Playlist.build(
|
|
230
|
+
independent_segments: true
|
|
231
|
+
) do
|
|
232
|
+
playlist program_id: '1', bandwidth: 6400,
|
|
233
|
+
audio_codec: 'mp3', uri: 'lo/index.m3u8'
|
|
234
|
+
playlist program_id: '2', bandwidth: 50_000,
|
|
235
|
+
width: 1920, height: 1080,
|
|
236
|
+
profile: 'high', level: 4.1,
|
|
237
|
+
audio_codec: 'aac-lc',
|
|
238
|
+
uri: 'hi/index.m3u8'
|
|
239
|
+
session_data data_id: 'com.test.title',
|
|
240
|
+
value: 'Test', language: 'en'
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
expect(built.to_s).to eq(imperative.to_s)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
it 'produces identical output to imperative API ' \
|
|
247
|
+
'for a media playlist' do
|
|
248
|
+
imperative = M3u8::Playlist.new(
|
|
249
|
+
version: 7, cache: false, target: 12,
|
|
250
|
+
sequence: 1, type: 'VOD'
|
|
251
|
+
)
|
|
252
|
+
imperative.items << M3u8::KeyItem.new(
|
|
253
|
+
method: 'AES-128', uri: 'http://test.key',
|
|
254
|
+
iv: 'D512BBF', key_format: 'identity',
|
|
255
|
+
key_format_versions: '1/3'
|
|
256
|
+
)
|
|
257
|
+
imperative.items << M3u8::SegmentItem.new(
|
|
258
|
+
duration: 11.344644, segment: '00000.ts'
|
|
259
|
+
)
|
|
260
|
+
imperative.items << M3u8::DiscontinuityItem.new
|
|
261
|
+
imperative.items << M3u8::TimeItem.new(
|
|
262
|
+
time: '2024-06-01T12:00:00Z'
|
|
263
|
+
)
|
|
264
|
+
imperative.items << M3u8::SegmentItem.new(
|
|
265
|
+
duration: 11.261233, segment: '00001.ts'
|
|
266
|
+
)
|
|
267
|
+
imperative.items << M3u8::MapItem.new(
|
|
268
|
+
uri: 'init.mp4',
|
|
269
|
+
byterange: { length: 812, start: 0 }
|
|
270
|
+
)
|
|
271
|
+
imperative.items << M3u8::SegmentItem.new(
|
|
272
|
+
duration: 7.5, segment: '00002.ts'
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
built = M3u8::Playlist.build(
|
|
276
|
+
version: 7, cache: false, target: 12,
|
|
277
|
+
sequence: 1, type: 'VOD'
|
|
278
|
+
) do
|
|
279
|
+
key method: 'AES-128', uri: 'http://test.key',
|
|
280
|
+
iv: 'D512BBF', key_format: 'identity',
|
|
281
|
+
key_format_versions: '1/3'
|
|
282
|
+
segment duration: 11.344644, segment: '00000.ts'
|
|
283
|
+
discontinuity
|
|
284
|
+
time time: '2024-06-01T12:00:00Z'
|
|
285
|
+
segment duration: 11.261233, segment: '00001.ts'
|
|
286
|
+
map uri: 'init.mp4',
|
|
287
|
+
byterange: { length: 812, start: 0 }
|
|
288
|
+
segment duration: 7.5, segment: '00002.ts'
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
expect(built.to_s).to eq(imperative.to_s)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
it 'produces identical output to imperative API ' \
|
|
295
|
+
'for an LL-HLS playlist' do
|
|
296
|
+
sc = M3u8::ServerControlItem.new(
|
|
297
|
+
can_skip_until: 24.0, part_hold_back: 1.0,
|
|
298
|
+
can_block_reload: true
|
|
299
|
+
)
|
|
300
|
+
pi = M3u8::PartInfItem.new(part_target: 0.5)
|
|
301
|
+
opts = {
|
|
302
|
+
version: 9, target: 4, sequence: 100,
|
|
303
|
+
server_control: sc, part_inf: pi, live: true
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
imperative = M3u8::Playlist.new(opts)
|
|
307
|
+
imperative.items << M3u8::MapItem.new(
|
|
308
|
+
uri: 'init.mp4'
|
|
309
|
+
)
|
|
310
|
+
imperative.items << M3u8::SegmentItem.new(
|
|
311
|
+
duration: 4.0, segment: 'seg100.mp4'
|
|
312
|
+
)
|
|
313
|
+
imperative.items << M3u8::PartItem.new(
|
|
314
|
+
duration: 0.5, uri: 'seg101.0.mp4',
|
|
315
|
+
independent: true
|
|
316
|
+
)
|
|
317
|
+
imperative.items << M3u8::PreloadHintItem.new(
|
|
318
|
+
type: 'PART', uri: 'seg101.1.mp4'
|
|
319
|
+
)
|
|
320
|
+
imperative.items << M3u8::RenditionReportItem.new(
|
|
321
|
+
uri: '../alt/index.m3u8',
|
|
322
|
+
last_msn: 101, last_part: 0
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
built = M3u8::Playlist.build(opts) do
|
|
326
|
+
map uri: 'init.mp4'
|
|
327
|
+
segment duration: 4.0, segment: 'seg100.mp4'
|
|
328
|
+
part duration: 0.5, uri: 'seg101.0.mp4',
|
|
329
|
+
independent: true
|
|
330
|
+
preload_hint type: 'PART', uri: 'seg101.1.mp4'
|
|
331
|
+
rendition_report uri: '../alt/index.m3u8',
|
|
332
|
+
last_msn: 101, last_part: 0
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
expect(built.to_s).to eq(imperative.to_s)
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
|
@@ -236,6 +236,80 @@ describe M3u8::Playlist do
|
|
|
236
236
|
end
|
|
237
237
|
end
|
|
238
238
|
|
|
239
|
+
describe '#segments' do
|
|
240
|
+
it 'returns only segment items' do
|
|
241
|
+
file = File.open('spec/fixtures/playlist.m3u8')
|
|
242
|
+
playlist = described_class.read(file)
|
|
243
|
+
expect(playlist.segments).to all be_a(M3u8::SegmentItem)
|
|
244
|
+
expect(playlist.segments.size).to eq(138)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
describe '#playlists' do
|
|
249
|
+
it 'returns only playlist items' do
|
|
250
|
+
file = File.open('spec/fixtures/master.m3u8')
|
|
251
|
+
playlist = described_class.read(file)
|
|
252
|
+
expect(playlist.playlists).to all be_a(M3u8::PlaylistItem)
|
|
253
|
+
expect(playlist.playlists.size).to eq(6)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
describe '#media_items' do
|
|
258
|
+
it 'returns only media items' do
|
|
259
|
+
file = File.open('spec/fixtures/variant_audio.m3u8')
|
|
260
|
+
playlist = described_class.read(file)
|
|
261
|
+
expect(playlist.media_items).to all be_a(M3u8::MediaItem)
|
|
262
|
+
expect(playlist.media_items.size).to eq(6)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
describe '#keys' do
|
|
267
|
+
it 'returns only key items' do
|
|
268
|
+
file = File.open('spec/fixtures/encrypted.m3u8')
|
|
269
|
+
playlist = described_class.read(file)
|
|
270
|
+
expect(playlist.keys).to all be_a(M3u8::KeyItem)
|
|
271
|
+
expect(playlist.keys.size).to eq(2)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
describe '#maps' do
|
|
276
|
+
it 'returns only map items' do
|
|
277
|
+
file = File.open('spec/fixtures/map_playlist.m3u8')
|
|
278
|
+
playlist = described_class.read(file)
|
|
279
|
+
expect(playlist.maps).to all be_a(M3u8::MapItem)
|
|
280
|
+
expect(playlist.maps.size).to eq(1)
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
describe '#date_ranges' do
|
|
285
|
+
it 'returns only date range items' do
|
|
286
|
+
file = File.open('spec/fixtures/daterange_playlist.m3u8')
|
|
287
|
+
playlist = described_class.read(file)
|
|
288
|
+
expect(playlist.date_ranges)
|
|
289
|
+
.to all be_a(M3u8::DateRangeItem)
|
|
290
|
+
expect(playlist.date_ranges.size).to eq(3)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
describe '#parts' do
|
|
295
|
+
it 'returns only part items' do
|
|
296
|
+
file = File.open('spec/fixtures/ll_hls_playlist.m3u8')
|
|
297
|
+
playlist = described_class.read(file)
|
|
298
|
+
expect(playlist.parts).to all be_a(M3u8::PartItem)
|
|
299
|
+
expect(playlist.parts.size).to eq(5)
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
describe '#session_data' do
|
|
304
|
+
it 'returns only session data items' do
|
|
305
|
+
file = File.open('spec/fixtures/session_data.m3u8')
|
|
306
|
+
playlist = described_class.read(file)
|
|
307
|
+
expect(playlist.session_data)
|
|
308
|
+
.to all be_a(M3u8::SessionDataItem)
|
|
309
|
+
expect(playlist.session_data.size).to eq(3)
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
239
313
|
describe '#write' do
|
|
240
314
|
context 'when playlist is valid' do
|
|
241
315
|
it 'returns playlist text' do
|
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.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Seth Deckard
|
|
8
|
-
autorequire:
|
|
8
|
+
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-02-
|
|
11
|
+
date: 2026-02-28 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: bundler
|
|
@@ -144,6 +144,7 @@ files:
|
|
|
144
144
|
- Rakefile
|
|
145
145
|
- lib/m3u8.rb
|
|
146
146
|
- lib/m3u8/bitrate_item.rb
|
|
147
|
+
- lib/m3u8/builder.rb
|
|
147
148
|
- lib/m3u8/byte_range.rb
|
|
148
149
|
- lib/m3u8/content_steering_item.rb
|
|
149
150
|
- lib/m3u8/date_range_item.rb
|
|
@@ -194,6 +195,7 @@ files:
|
|
|
194
195
|
- spec/fixtures/variant_angles.m3u8
|
|
195
196
|
- spec/fixtures/variant_audio.m3u8
|
|
196
197
|
- spec/lib/m3u8/bitrate_item_spec.rb
|
|
198
|
+
- spec/lib/m3u8/builder_spec.rb
|
|
197
199
|
- spec/lib/m3u8/byte_range_spec.rb
|
|
198
200
|
- spec/lib/m3u8/content_steering_item_spec.rb
|
|
199
201
|
- spec/lib/m3u8/date_range_item_spec.rb
|
|
@@ -225,7 +227,7 @@ homepage: https://github.com/sethdeckard/m3u8
|
|
|
225
227
|
licenses:
|
|
226
228
|
- MIT
|
|
227
229
|
metadata: {}
|
|
228
|
-
post_install_message:
|
|
230
|
+
post_install_message:
|
|
229
231
|
rdoc_options: []
|
|
230
232
|
require_paths:
|
|
231
233
|
- lib
|
|
@@ -240,8 +242,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
240
242
|
- !ruby/object:Gem::Version
|
|
241
243
|
version: '0'
|
|
242
244
|
requirements: []
|
|
243
|
-
rubygems_version: 3.
|
|
244
|
-
signing_key:
|
|
245
|
+
rubygems_version: 3.0.3.1
|
|
246
|
+
signing_key:
|
|
245
247
|
specification_version: 4
|
|
246
248
|
summary: Generate and parse m3u8 playlists for HTTP Live Streaming (HLS).
|
|
247
249
|
test_files: []
|