m3u8
m3u8 provides easy generation and parsing of m3u8 playlists defined in RFC 8216 HTTP Live Streaming and its proposed successor draft-pantos-hls-rfc8216bis.
- Full coverage of RFC 8216 and draft-pantos-hls-rfc8216bis-19 (Protocol Version 13), including Low-Latency HLS and Content Steering.
- Provides parsing of an m3u8 playlist into an object model from any File, StringIO, or string.
- Provides ability to write playlist to a File or StringIO or expose as string via to_s.
- Distinction between a master and media playlist is handled automatically (single Playlist class).
- Automatic generation of codec strings for H.264, HEVC, AV1, AAC, AC-3, E-AC-3, FLAC, Opus, and MP3.
Requirements
Ruby 3.1+
Installation
Add this line to your application's Gemfile:
gem 'm3u8'
And then execute:
$ bundle
Or install it yourself as:
$ gem install m3u8
CLI
The gem includes a command-line tool for inspecting and validating playlists.
Inspect
Display playlist metadata and item summary:
$ m3u8 inspect master.m3u8
Type: Master
Independent Segments: Yes
Variants: 6
1920x1080 5042000 bps hls/1080/1080.m3u8
640x360 861000 bps hls/360/360.m3u8
Media: 2
Session Keys: 1
Session Data: 0
$ m3u8 inspect media.m3u8
Type: Media
Version: 4
Sequence: 1
Target: 12
Duration: 1371.99s
Playlist: VOD
Cache: No
Segments: 138
Keys: 0
Maps: 0
Reads from stdin when no file is given:
$ cat playlist.m3u8 | m3u8 inspect
Pass --json to emit the playlist as JSON instead of the summary:
$ m3u8 inspect --json master.m3u8
{"master":true,"version":null,...}
Validate
Check playlist validity (exit 0 for valid, 1 for invalid):
$ m3u8 validate playlist.m3u8
Valid
$ m3u8 validate bad.m3u8
Invalid
- Playlist contains both master and media items
Diff
Show structural differences between two playlists (exit 0 when identical, 1 when they differ):
$ m3u8 diff old.m3u8 new.m3u8
version: 4 => 7
target: 12 => 6
items[2].duration: 5.0 => 6.0
items[5]: added SegmentItem
Usage (Builder DSL)
Playlist.build provides a block-based DSL for concise playlist construction. It supports two forms:
# instance_eval form (clean DSL)
playlist = M3u8::Playlist.build(version: 4, target: 12) do
segment duration: 11.34, segment: '1080-7mbps00000.ts'
segment duration: 11.26, segment: '1080-7mbps00001.ts'
end
# yielded builder form (access outer scope)
playlist = M3u8::Playlist.build(version: 4) do |b|
files.each { |f| b.segment duration: 10.0, segment: f }
end
Build a master playlist:
playlist = M3u8::Playlist.build(independent_segments: true) do
media type: 'AUDIO', group_id: 'audio', name: 'English',
default: true, uri: 'eng/index.m3u8'
playlist bandwidth: 5_042_000, width: 1920, height: 1080,
profile: 'high', level: 4.1, audio_codec: 'aac-lc',
uri: 'hls/1080.m3u8'
playlist bandwidth: 2_387_000, width: 1280, height: 720,
profile: 'main', level: 3.1, audio_codec: 'aac-lc',
uri: 'hls/720.m3u8'
end
Build a media playlist:
playlist = M3u8::Playlist.build(version: 4, target: 12,
sequence: 1, type: 'VOD') do
key method: 'AES-128', uri: 'https://example.com/key.bin'
map uri: 'init.mp4'
segment duration: 11.34, segment: '00000.ts'
discontinuity
segment duration: 11.26, segment: '00001.ts'
end
Build an LL-HLS playlist:
sc = M3u8::ServerControlItem.new(
can_skip_until: 24.0, part_hold_back: 1.0,
can_block_reload: true
)
pi = M3u8::PartInfItem.new(part_target: 0.5)
playlist = M3u8::Playlist.build(
version: 9, target: 4, sequence: 100,
server_control: sc, part_inf: pi, live: true
) do
map uri: 'init.mp4'
segment duration: 4.0, segment: 'seg100.mp4'
part duration: 0.5, uri: 'seg101.0.mp4', independent: true
preload_hint type: 'PART', uri: 'seg101.1.mp4'
rendition_report uri: '../alt/index.m3u8',
last_msn: 101, last_part: 0
end
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.
Usage (creating playlists)
Create a master playlist and add child playlists for adaptive bitrate streaming:
require 'm3u8'
playlist = M3u8::Playlist.new
Create a new playlist item with options:
= { width: 1920, height: 1080, profile: 'high', level: 4.1,
audio_codec: 'aac-lc', bandwidth: 540, uri: 'test.url' }
item = M3u8::PlaylistItem.new()
playlist.items << item
Add alternate audio, camera angles, closed captions and subtitles by creating MediaItem instances and adding them to the Playlist:
hash = { type: 'AUDIO', group_id: 'audio-lo', language: 'fre',
assoc_language: 'spoken', name: 'Francais', autoselect: true,
default: false, forced: true, uri: 'frelo/prog_index.m3u8' }
item = M3u8::MediaItem.new(hash)
playlist.items << item
Add Content Steering for dynamic CDN pathway selection:
item = M3u8::ContentSteeringItem.new(
server_uri: 'https://example.com/steering',
pathway_id: 'CDN-A'
)
playlist.items << item
Add variable definitions:
item = M3u8::DefineItem.new(name: 'base', value: 'https://example.com')
playlist.items << item
Add a session-level encryption key (master playlists):
item = M3u8::SessionKeyItem.new(
method: 'AES-128', uri: 'https://example.com/key.bin'
)
playlist.items << item
Add session-level data (master playlists):
item = M3u8::SessionDataItem.new(
data_id: 'com.example.title', value: 'My Video',
language: 'en'
)
playlist.items << item
Create a standard playlist and add MPEG-TS segments via SegmentItem:
= { version: 1, cache: false, target: 12, sequence: 1 }
playlist = M3u8::Playlist.new()
item = M3u8::SegmentItem.new(duration: 11, segment: 'test.ts')
playlist.items << item
Media segment tags
Add an encryption key for subsequent segments:
item = M3u8::KeyItem.new(
method: 'AES-128',
uri: 'https://example.com/key.bin',
iv: '0x1234567890abcdef1234567890abcdef'
)
playlist.items << item
Specify an initialization segment (e.g. fMP4 header):
item = M3u8::MapItem.new(
uri: 'init.mp4', byterange: { length: 812, start: 0 }
)
playlist.items << item
Insert a timed metadata date range:
item = M3u8::DateRangeItem.new(
id: 'ad-break-1', start_date: '2024-06-01T12:00:00Z',
planned_duration: 30.0, cue: 'PRE',
client_attributes: { 'X-AD-ID' => '"foo"' }
)
playlist.items << item
HLS Interstitials
DateRangeItem supports HLS Interstitials attributes as first-class accessors for ad insertion, pre/post-rolls, and timeline integration:
item = M3u8::DateRangeItem.new(
id: 'ad-break-1',
class_name: 'com.apple.hls.interstitial',
start_date: '2024-06-01T12:00:00Z',
asset_uri: 'http://example.com/ad.m3u8',
resume_offset: 0.0,
playout_limit: 30.0,
restrict: 'SKIP,JUMP',
snap: 'OUT',
content_may_vary: 'YES'
)
playlist.items << item
| HLS Attribute | Accessor | Type |
|---|---|---|
| X-ASSET-URI | asset_uri |
String |
| X-ASSET-LIST | asset_list |
String |
| X-RESUME-OFFSET | resume_offset |
Float |
| X-PLAYOUT-LIMIT | playout_limit |
Float |
| X-RESTRICT | restrict |
String |
| X-SNAP | snap |
String |
| X-TIMELINE-OCCUPIES | timeline_occupies |
String |
| X-TIMELINE-STYLE | timeline_style |
String |
| X-CONTENT-MAY-VARY | content_may_vary |
String |
Signal an encoding discontinuity:
playlist.items << M3u8::DiscontinuityItem.new
Attach a program date/time to the next segment:
item = M3u8::TimeItem.new(time: Time.iso8601('2024-06-01T12:00:00Z'))
playlist.items << item
Mark a gap in segment availability:
playlist.items << M3u8::GapItem.new
Add a bitrate hint for upcoming segments:
item = M3u8::BitrateItem.new(bitrate: 1500)
playlist.items << item
Low-Latency HLS
Create an LL-HLS playlist with server control, partial segments, and preload hints:
server_control = M3u8::ServerControlItem.new(
can_skip_until: 24.0, part_hold_back: 1.0,
can_block_reload: true
)
part_inf = M3u8::PartInfItem.new(part_target: 0.5)
playlist = M3u8::Playlist.new(
version: 9, target: 4, sequence: 100,
server_control: server_control, part_inf: part_inf,
live: true
)
item = M3u8::SegmentItem.new(duration: 4.0, segment: 'seg100.mp4')
playlist.items << item
part = M3u8::PartItem.new(
duration: 0.5, uri: 'seg101.0.mp4', independent: true
)
playlist.items << part
hint = M3u8::PreloadHintItem.new(type: 'PART', uri: 'seg101.1.mp4')
playlist.items << hint
report = M3u8::RenditionReportItem.new(
uri: '../alt/index.m3u8', last_msn: 101, last_part: 0
)
playlist.items << report
Writing playlists
You can pass an IO object to the write method:
require 'tempfile'
file = Tempfile.new('test')
playlist.write(file)
You can also access the playlist as a string:
playlist.to_s
M3u8::Writer is the class that handles generating the playlist output.
Alternatively you can set codecs rather than having it generated automatically:
= { width: 1920, height: 1080, codecs: 'avc1.66.30,mp4a.40.2',
bandwidth: 540, uri: 'test.url' }
item = M3u8::PlaylistItem.new()
Frozen playlists
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:
playlist = M3u8::Playlist.read(File.open('master.m3u8'))
playlist.frozen? # => true
playlist.items.frozen? # => true
playlist.items.first.frozen? # => true
Playlists created with Playlist.new remain mutable. Call freeze explicitly when ready:
playlist = M3u8::Playlist.new
playlist.items << M3u8::SegmentItem.new(duration: 10.0, segment: 'test.ts')
playlist.freeze
Frozen playlists still support to_s and write for output.
SCTE-35 parsing
DateRangeItem stores SCTE-35 values (scte35_cmd, scte35_out, scte35_in) as raw hex strings. Convenience methods parse them into structured objects:
playlist = M3u8::Playlist.read(file)
date_range = playlist.date_ranges.first
info = date_range.scte35_out_info
info.table_id # => 252 (0xFC)
info.pts_adjustment # => 0
info.tier # => 4095
info.splice_command_type # => 5
cmd = info.splice_command # => Scte35SpliceInsert
cmd.splice_event_id # => 1
cmd.out_of_network_indicator # => true
cmd.pts_time # => 90000
cmd.break_duration # => 2700000
cmd.break_auto_return # => true
Parse any SCTE-35 hex string directly:
info = M3u8::Scte35.parse('0xFC301100...')
info.to_s # => original hex string
Command types
| Type | Class | Key attributes |
|---|---|---|
| 0x00 | Scte35SpliceNull |
(none) |
| 0x05 | Scte35SpliceInsert |
splice_event_id, out_of_network_indicator, pts_time, break_duration, break_auto_return, unique_program_id, avail_num, avails_expected |
| 0x06 | Scte35TimeSignal |
pts_time |
Unknown command types store raw bytes in splice_command.
Descriptors
Segmentation descriptors (tag 0x02, identifier CUEI) are parsed as Scte35SegmentationDescriptor:
desc = info.descriptors.first
desc.segmentation_event_id # => 1
desc.segmentation_type_id # => 0x30
desc.segmentation_duration # => 2700000
desc.segmentation_upid_type # => 9
desc.segmentation_upid # => "SIGNAL123"
desc.segment_num # => 0
desc.segments_expected # => 0
Unknown descriptor tags store raw bytes.
Validation
Check whether a playlist is valid and inspect specific errors:
playlist.valid?
# => true
playlist.errors
# => []
When a playlist has issues, errors returns descriptive messages:
playlist.valid?
# => false
playlist.errors
# => ["Playlist contains both master and media items"]
The following validations are performed:
- Mixed item types (both master and media items in one playlist)
- Target duration less than any segment's rounded duration
- Segment items missing a URI or having a negative duration
- Playlist items missing a URI or valid bandwidth
- Media items missing type, group ID, or name
- Key and session key items missing a URI when method is not NONE
- Session data items missing data ID, or having both/neither value and URI
- Part items missing a URI or duration
valid? delegates to errors.empty? and both are recomputed on each call.
Separately, warnings returns non-fatal conformance advisories that do not
affect valid?: protocol-version compatibility (e.g. EXT-X-MAP requires
version 6) and Low-Latency HLS recommendations (PART-HOLD-BACK and
CAN-SKIP-UNTIL thresholds, required EXT-X-PART-INF):
playlist.warnings
# => ["EXT-X-MAP requires version 6 (version 5)"]
The m3u8 validate command prints these under a Warnings: heading after
the validity result.
Usage (parsing playlists)
file = File.open 'spec/fixtures/master.m3u8'
playlist = M3u8::Playlist.read(file)
playlist.master?
# => true
By default, unrecognized #EXT tags are ignored and collected in
unknown_tags. Pass strict: true to raise on the first one instead:
playlist = M3u8::Playlist.read(file)
playlist.
# => ["#EXT-X-FUTURE-TAG:1"]
M3u8::Playlist.read(file, strict: true)
# => raises M3u8::InvalidPlaylistError on an unsupported tag
EXT-X-DEFINE variables are preserved verbatim on parse. Call
resolve_variables to expand {$NAME} references (in URIs and string
attribute values) into a new playlist:
resolved = playlist.resolve_variables
# IMPORT and QUERYPARAM definitions take their values from arguments:
resolved = playlist.resolve_variables(
imported: { 'host' => 'https://cdn.example.com' },
query: { 'token' => 'abc123' }
)
Resolving raises M3u8::InvalidPlaylistError for an undefined reference
or a missing IMPORT/QUERYPARAM value.
Query playlist properties:
playlist.master?
# => true (contains variant streams)
playlist.live?
# => false (master playlists are never live)
For media playlists, duration returns total segment duration:
media = M3u8::Playlist.read(
File.open('spec/fixtures/event_playlist.m3u8')
)
media.live?
# => false
media.duration
# => 17.0 (sum of all segment durations)
Access items and their attributes:
playlist.items.first
# => #<M3u8::PlaylistItem ...>
media.segments.first.duration
# => 6.0
media.segments.first.segment
# => "segment0.mp4"
Convenience methods filter items by type:
playlist.playlists # => [PlaylistItem, ...]
playlist.segments # => [SegmentItem, ...]
playlist.media_items # => [MediaItem, ...]
playlist.keys # => [KeyItem, ...]
playlist.maps # => [MapItem, ...]
playlist.date_ranges # => [DateRangeItem, ...]
playlist.parts # => [PartItem, ...]
playlist.session_data # => [SessionDataItem, ...]
Parse an LL-HLS playlist:
file = File.open 'spec/fixtures/ll_hls_playlist.m3u8'
playlist = M3u8::Playlist.read(file)
playlist.server_control.can_block_reload
# => true
playlist.part_inf.part_target
# => 0.5
M3u8::Reader is the class that handles parsing if you want more control over the process.
Serialization (Hash and JSON)
Convert a playlist (and every item) to a plain Hash or JSON for interop with other tooling, and rebuild a playlist from that Hash or JSON:
playlist = M3u8::Playlist.read(File.open('spec/fixtures/master.m3u8'))
hash = playlist.to_h
# => { master: true, version: nil, ..., items: [{ ..., item_type: "PlaylistItem" }] }
json = playlist.to_json
# => "{\"master\":true,...}"
# Each item also responds to to_h / as_json / to_json
playlist.playlists.first.to_h
# => { program_id: "1", codecs: "avc1.640028,mp4a.40.2", bandwidth: 5_042_000, ... }
# Rebuild from a Hash or from parsed JSON
M3u8::Playlist.from_h(hash)
M3u8::Playlist.from_h(JSON.parse(json))
Each item Hash carries an :item_type key identifying its class, which
from_h uses to reconstruct the correct item. Defining to_json/as_json
overrides the generic implementations that the json library and
ActiveSupport otherwise provide for these objects.
Codec string generation
Generate the codec string based on audio and video codec options without dealing with a playlist instance:
= { profile: 'baseline', level: 3.0, audio_codec: 'aac-lc' }
codecs = M3u8::Playlist.codecs()
# => "avc1.66.30,mp4a.40.2"
Video codecs
| Profile | Description |
|---|---|
baseline, main, high |
H.264/AVC |
hevc-main, hevc-main-10 |
HEVC/H.265 |
av1-main, av1-high |
AV1 |
Audio codecs
| Value | Codec |
|---|---|
aac-lc |
AAC-LC |
he-aac |
HE-AAC |
mp3 |
MP3 |
ac-3 |
AC-3 (Dolby Digital) |
ec-3, e-ac-3 |
E-AC-3 (Dolby Digital Plus) |
flac |
FLAC |
opus |
Opus |
Supported tags
Master playlist tags
EXT-X-STREAM-INF/EXT-X-I-FRAME-STREAM-INF— includingSTABLE-VARIANT-ID,VIDEO-RANGE,ALLOWED-CPC,PATHWAY-ID,REQ-VIDEO-LAYOUT,SUPPLEMENTAL-CODECS,SCOREEXT-X-MEDIA— includingSTABLE-RENDITION-ID,BIT-DEPTH,SAMPLE-RATEEXT-X-SESSION-DATAEXT-X-SESSION-KEYEXT-X-CONTENT-STEERING
Media playlist tags
EXT-X-TARGETDURATIONEXT-X-MEDIA-SEQUENCEEXT-X-DISCONTINUITY-SEQUENCEEXT-X-PLAYLIST-TYPEEXT-X-I-FRAMES-ONLYEXT-X-ALLOW-CACHEEXT-X-ENDLIST
Media segment tags
EXTINFEXT-X-BYTERANGEEXT-X-DISCONTINUITYEXT-X-KEYEXT-X-MAPEXT-X-PROGRAM-DATE-TIMEEXT-X-DATERANGEEXT-X-GAPEXT-X-BITRATE
Universal tags
EXT-X-INDEPENDENT-SEGMENTSEXT-X-STARTEXT-X-DEFINEEXT-X-VERSION
Low-Latency HLS tags
EXT-X-SERVER-CONTROLEXT-X-PART-INFEXT-X-PARTEXT-X-SKIPEXT-X-PRELOAD-HINTEXT-X-RENDITION-REPORT
Contributing
- Fork it ( https://github.com/sethdeckard/m3u8/fork )
- Create your feature branch (
git checkout -b my-new-feature) - Run the specs, make sure they pass and that new features are covered. Code coverage should be 100%.
- Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
License
MIT License - See LICENSE.txt for details.