Created
January 3, 2019 20:41
-
-
Save jasonbyrne/d0c42365cb8bcc5855ba5e5353995cd5 to your computer and use it in GitHub Desktop.
HLS Checker Lambda - Loads an HLS playlist and then all of the underlying chunklists and segments to make sure all of the files exist and basic validity checks.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const HLS = require('hls-parser'); | |
const request = require('request'); | |
const URL = require('url').URL | |
// Error codes | |
const ERROR = { | |
INVALID_PLAYLIST_URL: { | |
code: 100, | |
message: 'Invalid playlist URL' | |
}, | |
HLS_PLAYLIST_PARSE_ERROR: { | |
code: 101, | |
message: 'HLS Parser reported invalid format of the playlist.' | |
}, | |
HLS_CHUNKLIST_PARSE_ERROR: { | |
code: 102, | |
message: 'HLS Parser reported invalid format of the chunklist.' | |
}, | |
NO_VARIANTS_FOUND: { | |
code: 103, | |
message: 'Playlist did not have any variants (renditions).' | |
}, | |
NO_SEGMENTS_FOUND: { | |
code: 104, | |
message: 'A chunklist did not have any segments.' | |
}, | |
PLAYLIST_LOAD_FAILED: { | |
code: 105, | |
message: 'The playlist file failed to load.' | |
}, | |
CHUKLIST_LOAD_FAILED: { | |
code: 106, | |
message: 'A chunklist file failed to load.' | |
}, | |
SEGMENT_LOAD_FAILED: { | |
code: 107, | |
message: 'A segment file failed to load.' | |
}, | |
UNKNOWN_EXCEPTION: { | |
code: 199, | |
message: 'Non-bucketed error was caught.' | |
} | |
}; | |
// Custom Exception Class | |
function HLS_Checker_Exception(err, context) { | |
err.context = context; | |
return err; | |
} | |
// Handle API call | |
exports.handler = async (event, context, callback) => { | |
const playlistUrl = event.queryStringParameters.url; | |
// This will get populated later to contain all files loaded | |
let files = []; | |
// Baseline response object | |
let response = { | |
request: { | |
playlistUri: playlistUrl, | |
started: (new Date().toISOString()), | |
finished: null, | |
allGood: false | |
}, | |
error: null, | |
files: [] | |
}; | |
// Response creator | |
function HLS_Checker_Response(statusCode) { | |
response.files = files; | |
response.request.finished = (new Date().toISOString()); | |
return { | |
statusCode: statusCode, | |
body: JSON.stringify(response, null, 2) | |
} | |
} | |
// Just bail out if there is no playlist url | |
if (!playlistUrl) { | |
response.error = new HLS_Checker_Exception(ERROR.INVALID_PLAYLIST_URL, 'URL value is : ' + playlistUrl); | |
return new HLS_Checker_Response(400); | |
} | |
function tsExists(uri) { | |
return new Promise((resolve, reject) => { | |
request(uri, { method: 'HEAD' }, function (err, res, body) { | |
if (err || res.statusCode < 200 || res.statusCode > 299) { | |
files.push({ | |
uri: uri, | |
type: 'segment', | |
loaded: false | |
}); | |
} | |
else { | |
files.push({ | |
uri: uri, | |
type: 'segment', | |
loaded: true | |
}); | |
resolve(); | |
} | |
// We'll resolve on pass or fail, failed load gets handled later | |
resolve(); | |
}); | |
}); | |
} | |
function getChunklist(chunklistUrl) { | |
return new Promise((resolve, reject) => { | |
request(chunklistUrl, function (err, res, body) { | |
// If error get out | |
if (err || res.statusCode < 200 || res.statusCode > 299) { | |
files.push({ | |
uri: chunklistUrl, | |
type: 'chunklist', | |
loaded: false | |
}); | |
// We'll resolve on pass or fail, failed load gets handled later | |
return resolve(); | |
} | |
// Got response so parse playlist | |
let chunklist; | |
try { | |
chunklist = HLS.parse(body); | |
} catch (err) { | |
return reject( | |
new HLS_Checker_Exception( | |
ERROR.HLS_CHUNKLIST_PARSE_ERROR, | |
'Error parsing chunklist ' + chunklistUrl + '. ERROR = ' + err + ' | CONTENT = ' + body | |
) | |
); | |
} | |
files.push({ | |
uri: chunklistUrl, | |
type: 'chunklist', | |
loaded: true | |
}); | |
let promises = []; | |
let segments = []; | |
if (chunklist.segments && Array.isArray(chunklist.segments) && chunklist.segments.length > 0) { | |
chunklist.segments.forEach(function (segment) { | |
let uri = new URL(segment.uri, chunklistUrl).href; | |
if (segments.indexOf(uri) < 0) { | |
segments.push(uri); | |
promises.push(tsExists(uri)); | |
} | |
}); | |
} | |
else { | |
return reject( | |
new HLS_Checker_Exception( | |
ERROR.NO_SEGMENTS_FOUND, | |
'No segments found in ' + chunklistUrl + '. CONTENT = ' + body | |
) | |
); | |
} | |
Promise.all(promises).then(function () { | |
resolve(segments); | |
}).catch(function (err) { | |
reject( | |
new HLS_Checker_Exception( | |
ERROR.UNKNOWN_EXCEPTION, | |
'Error on chunklist: ' + chunklistUrl + '. ERROR = ' + err | |
) | |
); | |
}); | |
}); | |
}); | |
} | |
function getPlaylist(playlistUrl) { | |
return new Promise((resolve, reject) => { | |
request(playlistUrl, function (err, res, body) { | |
// If error get out | |
if (err || res.statusCode < 200 || res.statusCode > 299) { | |
files.push({ | |
uri: playlistUrl, | |
type: 'playlist', | |
loaded: false | |
}); | |
// We'll resolve on pass or fail, failed load gets handled later | |
return resolve(); | |
} | |
// Fixing some errors on the fly that don't cause failure | |
body = body.replace(/,AUDIO="audio-0"/g, ''); | |
// Got response so parse playlist | |
let playlist; | |
try { | |
playlist = HLS.parse(body); | |
} catch (err) { | |
return reject( | |
new HLS_Checker_Exception( | |
ERROR.HLS_PLAYLIST_PARSE_ERROR, | |
'Error parsing playlist ' + playlistUrl + '. ERROR = ' + err + ' | CONTENT = ' + body | |
) | |
); | |
} | |
files.push({ | |
uri: playlistUrl, | |
type: 'playlist', | |
loaded: true | |
}); | |
let promises = []; | |
if (playlist.variants && Array.isArray(playlist.variants) && playlist.variants.length > 0) { | |
playlist.variants.forEach(function (variant) { | |
promises.push(getChunklist(new URL(variant.uri, playlistUrl).href)); | |
}); | |
} | |
else { | |
return reject( | |
new HLS_Checker_Exception( | |
ERROR.NO_VARIANTS_FOUND, | |
'No renditions found in ' + playlistUrl + '. CONTENT = ' + body | |
) | |
); | |
} | |
Promise.all(promises).then(function () { | |
resolve(); | |
}).catch(function (err) { | |
reject( | |
new HLS_Checker_Exception( | |
ERROR.UNKNOWN_EXCEPTION, | |
'Error on playlist: ' + playlistUrl + '. ERROR = ' + err | |
) | |
); | |
}); | |
}); | |
}); | |
} | |
await getPlaylist(playlistUrl).then(function () { | |
response.request.allGood = (function () { | |
let isGood = true; | |
files.some(function (file) { | |
if (!file.loaded) { | |
isGood = false; | |
let err = ( | |
file.type == 'segment' ? | |
ERROR.SEGMENT_LOAD_FAILED : | |
file.type == 'chunklist' ? | |
ERROR.SEGMENT_LOAD_FAILED : | |
ERROR.PLAYLIST_LOAD_FAILED | |
); | |
response.error = new HLS_Checker_Exception( | |
err, | |
'Error loading: ' + file.uri | |
); | |
return true; | |
} | |
}); | |
return isGood; | |
})(); | |
}).catch(function (exception) { | |
response.error = exception; | |
}); | |
return new HLS_Checker_Response(200); | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"name": "hls-checker", | |
"version": "1.0.0", | |
"description": "", | |
"main": "index.js", | |
"scripts": { | |
"test": "echo \"Error: no test specified\" && exit 1" | |
}, | |
"author": "Jason Byrne <jason.byrne@flosports.tv>", | |
"license": "ISC", | |
"dependencies": { | |
"hls-parser": "^0.2.4", | |
"request": "^2.88.0" | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment