/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ package org.mangui.hls.playlist { import flash.events.Event; import flash.events.IOErrorEvent; import flash.events.ProgressEvent; import flash.events.SecurityErrorEvent; import flash.net.URLLoader; import flash.net.URLRequest; import flash.utils.ByteArray; import flash.utils.Dictionary; import flash.utils.getTimer; import org.mangui.hls.HLS; import org.mangui.hls.constant.HLSLoaderTypes; import org.mangui.hls.constant.HLSTypes; import org.mangui.hls.event.HLSLoadMetrics; import org.mangui.hls.model.Fragment; import org.mangui.hls.model.Level; import org.mangui.hls.utils.DateUtil; import org.mangui.hls.utils.Hex; import org.mangui.hls.utils.StringUtil; CONFIG::LOGGING { import org.mangui.hls.utils.Log; } /** Helpers for parsing M3U8 files. **/ public class Manifest { /** Starttag for a fragment. **/ public static const FRAGMENT : String = '#EXTINF:'; /** Header tag that must be at the first line. **/ public static const HEADER : String = '#EXTM3U'; /** Version of the playlist file. **/ public static const VERSION : String = '#EXT-X-VERSION'; /** Starttag for a level. **/ public static const LEVEL : String = '#EXT-X-STREAM-INF:'; /** Tag that delimits the end of a playlist. **/ public static const ENDLIST : String = '#EXT-X-ENDLIST'; /** Tag that provides info related to alternative audio tracks */ public static const ALTERNATE_AUDIO : String = '#EXT-X-MEDIA:TYPE=AUDIO,'; /** Tag that provides info related to alternative rendition */ private static const MEDIA : String = '#EXT-X-MEDIA:'; /** Tag that provides the sequence number. **/ private static const SEQNUM : String = '#EXT-X-MEDIA-SEQUENCE:'; /** Tag that provides the target duration for each segment. **/ private static const TARGETDURATION : String = '#EXT-X-TARGETDURATION:'; /** Tag that indicates discontinuity in the stream */ private static const DISCONTINUITY : String = '#EXT-X-DISCONTINUITY'; /** Tag that indicates discontinuity sequence in the stream */ private static const DISCONTINUITY_SEQ : String = '#EXT-X-DISCONTINUITY-SEQUENCE:'; /** Tag that provides date/time information */ private static const PROGRAMDATETIME : String = '#EXT-X-PROGRAM-DATE-TIME:'; /** Tag that provides fragment decryption info */ private static const KEY : String = '#EXT-X-KEY:'; /** Tag that provides byte range info */ private static const BYTERANGE : String = '#EXT-X-BYTERANGE:'; /** useful regular expression */ private static const replacespace : RegExp = new RegExp("\\s+", "g"); private static const replacesinglequote : RegExp = new RegExp("\\\'", "g"); private static const replacedoublequote : RegExp = new RegExp("\\\"", "g"); private static const trimwhitespace : RegExp = /^\s*|\s*$/gim; /** Index in the array with levels. **/ private var _index : int; /** URLLoader instance. **/ private var _urlloader : URLLoader; /** Function to callback loading to. **/ private var _success : Function; /** URL of an M3U8 playlist. **/ private var _url : String; /** load metrics **/ private var _metrics : HLSLoadMetrics; /** Load a playlist M3U8 file. **/ public function loadPlaylist(hls : HLS, url : String, success : Function, error : Function, index : int, type : String, flushLiveURLcache : Boolean) : void { _url = url; _success = success; _index = index; var urlLoaderClass : Class = hls.URLloader as Class; _urlloader = (new urlLoaderClass()) as URLLoader; _urlloader.addEventListener(Event.COMPLETE, _loadCompleteHandler); _urlloader.addEventListener(ProgressEvent.PROGRESS, _loadProgressHandler); _urlloader.addEventListener(IOErrorEvent.IO_ERROR, error); _urlloader.addEventListener(SecurityErrorEvent.SECURITY_ERROR, error); if (flushLiveURLcache && type == HLSTypes.LIVE) { /* add time parameter to force reload URL, there are some issues with browsers/CDN reloading from cache even if the URL has been updated ... see http://stackoverflow.com/questions/14448219/as3-resetting-urlloader-cache */ var extra : String = "time=" + new Date().getTime(); if (_url.indexOf("?") == -1) { url += "?" + extra; } else { url += "&" + extra; } } if (DataUri.isDataUri(url)) { CONFIG::LOGGING { Log.debug("Identified playlist <" + url + "> as a data URI."); } var data : String = new DataUri(url).extractData(); onLoadedData(data || ""); return; } _metrics = new HLSLoadMetrics(HLSLoaderTypes.LEVEL_MAIN); _metrics.level = index; _metrics.loading_request_time = getTimer(); _urlloader.load(new URLRequest(url)); }; /* cancel loading in progress */ public function close() : void { try { _urlloader.close(); } catch(e : Error) { } } /** loading progress handler, use to determine loading latency **/ private function _loadProgressHandler(event : Event) : void { if(_metrics.loading_begin_time == 0) { _metrics.loading_begin_time = getTimer(); } }; /** loading complete handler **/ private function _loadCompleteHandler(event : Event) : void { _metrics.loading_end_time = getTimer(); onLoadedData(StringUtil.toLF(String(_urlloader.data))); }; private function onLoadedData(data : String) : void { _success(data, _url, _index, _metrics); } private static function zeropad(str : String, length : uint) : String { while (str.length < length) { str = "0" + str; } return str; } /** Extract fragments from playlist data. **/ public static function getFragments(data : String, base : String, level : int) : Vector. { var fragments : Vector. = new Vector.(); var lines : Vector. = StringUtil.toLines(data); // fragment seqnum var seqnum : int = 0; // fragment start time (in sec) var start_time : Number = 0; // nb of ms since epoch var program_date : Number = 0; var program_date_defined : Boolean = false; /* URL of decryption key */ var decrypt_url : String = null; /* Initialization Vector */ var decrypt_iv : ByteArray = null; // fragment continuity index incremented at each discontinuity var continuity_index : int = 0; var i : int = 0; var extinf_found : Boolean = false; var byterange_start_offset : int = -1; var byterange_end_offset : int = -1; var tag_list : Vector. = new Vector.(); while (i < lines.length) { var line : String = lines[i++]; // discard blank line, length could be 0 or if DOS terminated line (CR/LF), only a CR char if (line.length <= 0 || line.indexOf("\r") == 0) { continue; } // discard tags pertaining to a playlist and not a fragment, that require no further processing if (line.indexOf(HEADER) == 0 || line.indexOf(TARGETDURATION) == 0 || line.indexOf(VERSION) == 0) { continue; } if (line.indexOf(SEQNUM) == 0) { seqnum = parseInt(line.substr(SEQNUM.length)); } else if (line.indexOf(DISCONTINUITY_SEQ) == 0) { continuity_index = parseInt(line.substr(DISCONTINUITY_SEQ.length)); } else if (line.indexOf(BYTERANGE) == 0) { var params : Array = line.substr(BYTERANGE.length).split('@'); if (params.length == 1) { byterange_start_offset = byterange_end_offset; } else { byterange_start_offset = parseInt(params[1]); } byterange_end_offset = parseInt(params[0]) + byterange_start_offset; tag_list.push(line); } else if (line.indexOf(KEY) == 0) { // #EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=52",IV=..... var keyLine : String = line.substr(KEY.length); // reset previous values decrypt_url = null; decrypt_iv = null; // remove space, single and double quote keyLine = keyLine.replace(replacespace, ""); keyLine = keyLine.replace(replacesinglequote, ""); keyLine = keyLine.replace(replacedoublequote, ""); var keyArray : Array = keyLine.split(","); var keyArray2 : Array = new Array(); /* correctly parse #EXT-X-KEY:METHOD=AES-128,URI="Keys(LiveTest-m3u8-aapl,format=m3u8-aapl).key" * take into account comma not followed by attribute name, append value to previous entry */ for each (var key : String in keyArray) { if (key.indexOf("METHOD=") == 0 || key.indexOf("URI=") == 0 || key.indexOf("IV=") == 0 || key.indexOf("KEYFORMAT=") == 0 || key.indexOf("KEYFORMATVERSIONS=") == 0) { keyArray2.push(key); } else { if (keyArray2.length) { // append to previous element keyArray2[keyArray2.length - 1] += "," + key; } } } for each (var keyProperty : String in keyArray2) { var delimiter : int = keyProperty.indexOf("="); if (delimiter == -1) { throw new Error("invalid playlist, no delimiter while parsing:" + keyProperty); } var tag : String = keyProperty.substr(0, delimiter).toUpperCase(); var value : String = keyProperty.substr(delimiter + 1); switch(tag) { case "METHOD": switch (value) { case "NONE": case "AES-128": break; case "AES-SAMPLE": throw new Error("encryption method " + value + "not supported (yet ;-))"); default: throw new Error("invalid encryption method " + value); break; } break; case "URI": decrypt_url = _extractURL(value, base); break; case "IV": decrypt_iv = Hex.toArray(zeropad(value.substr("0x".length), 32)); break; case "KEYFORMAT": case "KEYFORMATVERSIONS": default: break; } } tag_list.push(line); } else if (line.indexOf(PROGRAMDATETIME) == 0) { // CONFIG::LOGGING { // Log.info(line); // } program_date = DateUtil.parseW3CDTF(line.substr(PROGRAMDATETIME.length)).getTime(); program_date_defined = true; tag_list.push(line); } else if (line.indexOf(DISCONTINUITY) == 0) { continuity_index++; tag_list.push(line); } else if (line.indexOf(FRAGMENT) == 0) { var comma_position : int = line.indexOf(','); var duration : Number = (comma_position == -1) ? parseFloat(line.substr(FRAGMENT.length)) : parseFloat(line.substr(FRAGMENT.length, comma_position - FRAGMENT.length)); extinf_found = true; tag_list.push(line); } else if (line.indexOf('#') == 0) { // unsupported/custom tags, store them tag_list.push(line); } else if (extinf_found == true) { var url : String = _extractURL(line, base); var fragment_decrypt_iv : ByteArray; if (decrypt_url != null) { /* as per HLS spec : if IV not defined, then use seqnum as IV : http://tools.ietf.org/html/draft-pantos-http-live-streaming-11#section-5.2 */ if (decrypt_iv != null) { fragment_decrypt_iv = decrypt_iv; } else { fragment_decrypt_iv = Hex.toArray(zeropad(seqnum.toString(16), 32)); } CONFIG::LOGGING { Log.debug("sn/key/iv:" + seqnum + "/" + decrypt_url + "/" + Hex.fromArray(fragment_decrypt_iv)); } } else { fragment_decrypt_iv = null; } fragments.push(new Fragment(url, duration, level, seqnum++, start_time, continuity_index, program_date, decrypt_url, fragment_decrypt_iv, byterange_start_offset, byterange_end_offset, tag_list)); start_time += duration; if (program_date_defined) { program_date += 1000 * duration; } extinf_found = false; tag_list = new Vector.(); byterange_start_offset = -1; } } if (fragments.length == 0) { // throw new Error("No TS fragments found in " + base); null; // just to avoid compilation warnings if CONFIG::LOGGING is false CONFIG::LOGGING { Log.warn("No TS fragments found in " + base); } } return fragments; }; /** Extract levels from manifest data. **/ public static function extractLevels(data : String, base : String = '') : Vector. { var levels : Vector. = new Vector.(); var bitrateDictionary : Dictionary = new Dictionary(); var level : Level; var lines : Vector. = StringUtil.toLines(data); var level_found : Boolean = false; var i : int = 0; while (i < lines.length) { var line : String = lines[i++]; // discard blank line, length could be 0 or 1 if DOS terminated line (CR/LF) if (line.length <= 1) { continue; } if (line.indexOf(LEVEL) == 0) { level_found = true; level = new Level(); var params : Array = line.substr(LEVEL.length).split(','); for (var j : int = 0; j < params.length; j++) { var param : String = params[j]; if (param.indexOf('BANDWIDTH') > -1) { level.bitrate = parseInt(param.split('=')[1]); } else if (param.indexOf('RESOLUTION') > -1) { var res : String = param.split('=')[1] as String; var dim : Array = res.split('x'); level.width = parseInt(dim[0]); level.height = parseInt(dim[1]); } else if (param.indexOf('CODECS') > -1) { if (line.indexOf('avc1') > -1) { level.codec_h264 = true; } else { level.audio = true; } if (line.indexOf('mp4a.40.2') > -1 || line.indexOf('mp4a.40.5') > -1) { level.codec_aac = true; } else if (line.indexOf('mp4a.40.34') > -1) { level.codec_mp3 = true; } } else if (param.indexOf('AUDIO') > -1) { level.audio_stream_id = (param.split('=')[1] as String).replace(replacedoublequote, "").replace(trimwhitespace, ""); } else if (param.indexOf('CLOSED-CAPTIONS') > -1) { level.closed_captions = (param.split('=')[1] as String).replace(replacedoublequote, "").replace(trimwhitespace, ""); } else if (param.indexOf('NAME') > -1) { level.name = (param.split('=')[1] as String).replace(replacedoublequote, ""); } } } else if (level_found == true) { if(!(level.bitrate in bitrateDictionary)) { level.urls = new Vector.(); level.urls.push(_extractURL(line, base)); level.manifest_index = levels.length; levels.push(level); bitrateDictionary[level.bitrate] = level; } else { level = bitrateDictionary[level.bitrate]; var redundantURL:String = _extractURL(line, base); level.urls.push(redundantURL); CONFIG::LOGGING { Log.debug("found failover level with url " + redundantURL); } } level_found = false; } } levels.sort(compareLevel); for (i = 0; i < levels.length; i++) { levels[i].index = i; } return levels; }; /* compare level, smallest bitrate first */ private static function compareLevel(x : Level, y : Level) : Number { return (x.bitrate - y.bitrate); } /** Extract Alternate Audio Tracks from manifest data. **/ public static function extractAltAudioTracks(data : String, base : String = '') : Vector. { var altAudioTracks : Vector. = new Vector.(); var lines : Vector. = StringUtil.toLines(data); var i : int = 0; while (i < lines.length) { var line : String = lines[i++]; if (line.indexOf(MEDIA) == 0) { var params : Object = _parseAlternateRendition(line); // #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="bipbop_audio",LANGUAGE="eng",NAME="BipBop Audio 1",AUTOSELECT=YES,DEFAULT=YES // #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="bipbop_audio",LANGUAGE="eng",NAME="BipBop Audio 2",AUTOSELECT=NO,DEFAULT=NO,URI="alternate_audio_aac_sinewave/prog_index.m3u8" // #EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="English",DEFAULT=YES,AUTOSELECT=YES,FORCED=NO,LANGUAGE="eng",URI="captions.m3u8" var uri : String = params['URI']; if (uri) { uri = _extractURL(uri, base); } if (params['TYPE'] == 'AUDIO') { var alternate_audio : AltAudioTrack = new AltAudioTrack(params['GROUP-ID'], params['LANGUAGE'], params['NAME'], params['AUTOSELECT'] == 'YES', params['DEFAULT'] == 'YES', uri); altAudioTracks.push(alternate_audio); } } } return altAudioTracks; }; private static const RENDITION_STATE_READKEY : Number = 1; private static const RENDITION_STATE_READVALUESTART : Number = 2; private static const RENDITION_STATE_READSIMPLEVALUE : Number = 3; private static const RENDITION_STATE_READQUOTEDVALUE : Number = 4; private static const STATE_READQUOTEDVALUE_END : Number = 5; private static function _parseAlternateRendition(line : String) : Object { var variables : Object = new Object(); var state : Number = RENDITION_STATE_READKEY; var pos : Number = 0; var c : String; var key : String = ""; var value : String = ""; line = line.substr(MEDIA.length); while (pos < line.length) { c = line.charAt(pos); pos++; switch (state) { case RENDITION_STATE_READKEY: if (c == '=') { state = RENDITION_STATE_READVALUESTART; } else { key += c; } break; case RENDITION_STATE_READVALUESTART: if (c == '"') { state = RENDITION_STATE_READQUOTEDVALUE; } else { value += c; state = RENDITION_STATE_READSIMPLEVALUE; } break; case RENDITION_STATE_READSIMPLEVALUE: if (c == ",") { variables[StringUtil.trim(key)] = value; key = ""; value = ""; state = RENDITION_STATE_READKEY; } else { value += c; } break; case RENDITION_STATE_READQUOTEDVALUE: if (c == '"') { state = STATE_READQUOTEDVALUE_END; } else { value += c; } break; case STATE_READQUOTEDVALUE_END: if (c == ",") { variables[StringUtil.trim(key)] = value; key = ""; value = ""; state = RENDITION_STATE_READKEY; } break; } } if (key) { variables[StringUtil.trim(key)] = value; } return variables; } /** Extract whether the stream is live or ondemand. **/ public static function hasEndlist(data : String) : Boolean { if (data.indexOf(ENDLIST) > 0) { return true; } else { return false; } }; public static function getTargetDuration(data : String) : Number { var lines : Vector. = StringUtil.toLines(data); var i : int = 0; var targetduration : Number = 0; // first look for target duration while (i < lines.length) { var line : String = lines[i++]; if (line.indexOf(TARGETDURATION) == 0) { targetduration = parseFloat(line.substr(TARGETDURATION.length)); break; } } return targetduration; } /** Extract URL (check if absolute or not). **/ private static function _extractURL(path : String, base : String) : String { var _prefix : String = null; var _suffix : String = null; // trim white space if any path.replace(replacespace, ""); if (path.substr(0, 7) == 'http://' || path.substr(0, 8) == 'https://') { return path; } else { // Remove querystring if (base.indexOf('?') > -1) { base = base.substr(0, base.indexOf('?')); } // domain-absolute if (path.charAt(0) == '/') { // base = http[s]://domain/subdomain:1234/otherstuff // prefix = http[s]:// // suffix = domain/subdomain:1234/otherstuff _prefix = base.substr(0, base.indexOf("//") + 2); _suffix = base.substr(base.indexOf("//") + 2); if(path.charAt(1) == '/') { return _prefix + path.substr(2); } else { // return http[s]://domain/subdomain:1234/path return _prefix + _suffix.substr(0, _suffix.indexOf("/")) + path; } } else { return base.substr(0, base.lastIndexOf('/') + 1) + path; } } }; } }