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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2be533f088c62f6247e71b571a4481c92e56f8d575511ecae16be472dd90006a
4
- data.tar.gz: 3d6de6bef331beefea33a156f6129dbcb30c89b195d807440acd740b53027a1b
3
+ metadata.gz: 7a9a7d6e157afd5fd7028323544bf9d851bd898890f90c66af6f64e672d7e456
4
+ data.tar.gz: ee452e25dfb96ee9634e9a00c287a87ac4640dceb2d965550e31199e0c0b4e26
5
5
  SHA512:
6
- metadata.gz: edd32a67f619c6fd6b234163cbd16837cd92100f9d84f3729dd1ab09d6fdb1467f8cdab9e89cf64db344d4bb4359620c7aaad3d8532cb33195734a8c22833874
7
- data.tar.gz: c24310d02ff3f844640618cf73fba927fd06bdf2f24e0c20d65166b459092f605d59f1ad408853342c3196e3eceed9535ef08a71c7c75bed8024d5f6383d2f8e
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
- - Small commits that can each be deployed independently
6
- - Each commit must not break production (CI/CD safe)
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, Start, Stop, Document, Reword
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
- Never deploy anything.
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
  [![CI](https://github.com/sethdeckard/m3u8/actions/workflows/ci.yml/badge.svg)](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 the [HTTP Live Streaming (HLS)](https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis) 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).
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
- Access items in playlist:
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`
@@ -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 = 0.0
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
- items.count { |item| item.is_a?(PlaylistItem) }
134
+ playlists.size
96
135
  end
97
136
 
98
137
  def segment_size
99
- items.count { |item| item.is_a?(SegmentItem) }
138
+ segments.size
100
139
  end
101
140
  end
102
141
  end
data/lib/m3u8/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  # M3u8 provides parsing, generation, and validation of m3u8 playlists
4
4
  module M3u8
5
- VERSION = '1.0.0'
5
+ VERSION = '1.2.0'
6
6
  end
@@ -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.0.0
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-07 00:00:00.000000000 Z
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.2.32
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: []