diff --git a/package-lock.json b/package-lock.json index 0794b1b..9757e63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1371,9 +1371,9 @@ "dev": true }, "@types/node": { - "version": "14.14.20", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.20.tgz", - "integrity": "sha512-Y93R97Ouif9JEOWPIUyU+eyIdyRqQR0I8Ez1dzku4hDx34NWh4HbtIc3WNzwB1Y9ULvNGeu5B8h8bVL5cAk4/A==", + "version": "14.14.22", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.22.tgz", + "integrity": "sha512-g+f/qj/cNcqKkc3tFqlXOYjrmZA+jNBiDzbP3kH+B+otKFqAdPgVTGP1IeKRdMml/aE69as5S4FqtxAbl+LaMw==", "dev": true }, "@types/normalize-package-data": { @@ -2079,9 +2079,9 @@ } }, "caniuse-lite": { - "version": "1.0.30001174", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001174.tgz", - "integrity": "sha512-tqClL/4ThQq6cfFXH3oJL4rifFBeM6gTkphjao5kgwMaW9yn0tKgQLAEfKzDwj6HQWCB/aWo8kTFlSvIN8geEA==", + "version": "1.0.30001179", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001179.tgz", + "integrity": "sha512-blMmO0QQujuUWZKyVrD1msR4WNDAqb/UPO1Sw2WWsQ7deoM5bJiicKnWJ1Y0NS/aGINSnKPIWBMw5luX+NDUCA==", "dev": true }, "caporal": { @@ -2154,9 +2154,9 @@ "dev": true }, "chokidar": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.0.tgz", - "integrity": "sha512-JgQM9JS92ZbFR4P90EvmzNpSGhpPBGBSj10PILeDyYFwp4h2/D9OM03wsJ4zW1fEp4ka2DGrnUeD7FuvQ2aZ2Q==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.1.tgz", + "integrity": "sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw==", "dev": true, "requires": { "anymatch": "~3.1.1", @@ -2677,12 +2677,12 @@ "dev": true }, "core-js-compat": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.8.2.tgz", - "integrity": "sha512-LO8uL9lOIyRRrQmZxHZFl1RV+ZbcsAkFWTktn5SmH40WgLtSNYN4m4W2v9ONT147PxBY/XrRhrWq8TlvObyUjQ==", + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.8.3.tgz", + "integrity": "sha512-1sCb0wBXnBIL16pfFG1Gkvei6UzvKyTNYpiC41yrdjEv0UoJoq9E/abTMzyYJ6JpTkAj15dLjbqifIzEBDVvog==", "dev": true, "requires": { - "browserslist": "^4.16.0", + "browserslist": "^4.16.1", "semver": "7.0.0" }, "dependencies": { @@ -3078,9 +3078,9 @@ "dev": true }, "electron-to-chromium": { - "version": "1.3.636", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.636.tgz", - "integrity": "sha512-Adcvng33sd3gTjNIDNXGD1G4H6qCImIy2euUJAQHtLNplEKU5WEz5KRJxupRNIIT8sD5oFZLTKBWAf12Bsz24A==", + "version": "1.3.642", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.642.tgz", + "integrity": "sha512-cev+jOrz/Zm1i+Yh334Hed6lQVOkkemk2wRozfMF4MtTR7pxf3r3L5Rbd7uX1zMcEqVJ7alJBnJL7+JffkC6FQ==", "dev": true }, "emoji-regex": { @@ -6316,9 +6316,9 @@ } }, "mime": { - "version": "2.4.7", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.4.7.tgz", - "integrity": "sha512-dhNd1uA2u397uQk3Nv5LM4lm93WYDUXFn3Fu291FJerns4jyTudqhIWe4W04YLy7Uk1tm1Ore04NpjRvQp/NPA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.5.0.tgz", + "integrity": "sha512-ft3WayFSFUVBuJj7BMLKAQcSlItKtfjsKDDsii3rqFDAZ7t11zRe8ASw/GlmivGwVUYtwkQrxiGGpL6gFvB0ag==", "dev": true }, "mime-db": { @@ -6455,9 +6455,9 @@ } }, "node-releases": { - "version": "1.1.69", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.69.tgz", - "integrity": "sha512-DGIjo79VDEyAnRlfSqYTsy+yoHd2IOjJiKUozD2MV2D85Vso6Bug56mb9tT/fY5Urt0iqk01H7x+llAruDR2zA==", + "version": "1.1.70", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.70.tgz", + "integrity": "sha512-Slf2s69+2/uAD79pVVQo8uSiC34+g8GWY8UH2Qtqv34ZfhYrxpYpfzs9Js9d6O0mbDmALuxaTlplnBTnSELcrw==", "dev": true }, "node-watch": { @@ -7047,9 +7047,9 @@ "dev": true }, "qunit": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/qunit/-/qunit-2.12.0.tgz", - "integrity": "sha512-Lu3tbKziVzXTfseoEtTiiSAbSPB6SGU4Emc2uo8n+fbsXuRCLzfqPwJfAVJwKu9NdukX1V/L0qWf2UvmPX+QeA==", + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/qunit/-/qunit-2.13.0.tgz", + "integrity": "sha512-RvJquyNKbMSn5Qo28S2wKWxHl1Ku8m0zFLTKsXfq/WZkyM+b28gpEs6YkKN1fOCV4S+979+GnevD0FRgQayo3Q==", "dev": true, "requires": { "commander": "6.2.0", @@ -7444,9 +7444,9 @@ } }, "rfdc": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.1.4.tgz", - "integrity": "sha512-5C9HXdzK8EAqN7JDif30jqsBzavB7wLpaubisuQIGHWf2gUXSpzy6ArX/+Da8RjFpagWsCn+pIgxTMAmKw9Zug==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.2.0.tgz", + "integrity": "sha512-ijLyszTMmUrXvjSooucVQwimGUk84eRcmCuLV8Xghe3UO85mjUtRAHRyoMM6XtyqbECaXuBWx18La3523sXINA==", "dev": true }, "rimraf": { @@ -7459,9 +7459,9 @@ } }, "rollup": { - "version": "2.36.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.36.1.tgz", - "integrity": "sha512-eAfqho8dyzuVvrGqpR0ITgEdq0zG2QJeWYh+HeuTbpcaXk8vNFc48B7bJa1xYosTCKx0CuW+447oQOW8HgBIZQ==", + "version": "2.37.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.37.1.tgz", + "integrity": "sha512-V3ojEeyGeSdrMSuhP3diBb06P+qV4gKQeanbDv+Qh/BZbhdZ7kHV0xAt8Yjk4GFshq/WjO7R4c7DFM20AwTFVQ==", "dev": true, "requires": { "fsevents": "~2.1.2" @@ -8846,9 +8846,9 @@ } }, "videojs-generate-karma-config": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/videojs-generate-karma-config/-/videojs-generate-karma-config-7.0.0.tgz", - "integrity": "sha512-nC3SZeVx3OtNiCtdjkC+GT4imfuDhGhpaxr/sfNJv2QmvzKhAhGkySRrh01+SLF85e+yAaCeitDYkXP5QlqtJg==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/videojs-generate-karma-config/-/videojs-generate-karma-config-7.1.0.tgz", + "integrity": "sha512-j3ed19T+Aidjho+KmzMNKc0k8ySXW45vX+ON3YWR0HOxRFF5VATokIyZv3z0Z/aR5ImoiQAANDs8/zuRafC/mw==", "dev": true, "requires": { "is-ci": "^2.0.0", @@ -8862,13 +8862,13 @@ "karma-safari-applescript-launcher": "~0.1.0", "karma-static-server": "^1.0.0", "karma-teamcity-reporter": "^1.1.0", - "qunit": "~2.12.0" + "qunit": "~2.13.0" } }, "videojs-generate-rollup-config": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/videojs-generate-rollup-config/-/videojs-generate-rollup-config-6.1.0.tgz", - "integrity": "sha512-V4ehz26zKFSlfPTy6s5b/LwnxKZ1kn+Lx0p8XNC9s9app7ZEFb5j9AxfPmUpY728v1iltI0FgQsSZtCIbfo1Pg==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/videojs-generate-rollup-config/-/videojs-generate-rollup-config-6.2.0.tgz", + "integrity": "sha512-sa1S1Z6RHtGjGKAq8bpNIWYkGm3XbeyxXBn0HzCU+cgpfmDwwpJeuNXmf1HLq+x4Vg6AQOM+qCDI0GK53dCtww==", "dev": true, "requires": { "@rollup/plugin-babel": "^5.2.1", diff --git a/package.json b/package.json index 7b8c358..d321a87 100644 --- a/package.json +++ b/package.json @@ -73,11 +73,11 @@ "@rollup/plugin-replace": "^2.3.4", "@videojs/generator-helpers": "~2.0.1", "karma": "^5.2.3", - "rollup": "^2.36.1", + "rollup": "^2.37.1", "rollup-plugin-data-files": "^0.1.0", "sinon": "^9.2.3", - "videojs-generate-karma-config": "~7.0.0", - "videojs-generate-rollup-config": "~6.1.0", + "videojs-generate-karma-config": "~7.1.0", + "videojs-generate-rollup-config": "~6.2.0", "videojs-generator-verify": "~3.0.1", "videojs-standard": "^8.0.4" }, diff --git a/src/parser.js b/src/parser.js index e7dc109..101e7d9 100644 --- a/src/parser.js +++ b/src/parser.js @@ -6,6 +6,55 @@ import decodeB64ToUint8Array from '@videojs/vhs-utils/es/decode-b64-to-uint8-arr import LineStream from './line-stream'; import ParseStream from './parse-stream'; +// set SERVER-CONTROL hold back based upon targetDuration and partTargetDuration +// we need this helper because defaults are based upon targetDuration and +// partTargetDuration being set, but they may not be if SERVER-CONTROL appears before +// target durations are set. +const setHoldBack = function(manifest) { + const {serverControl, targetDuration, partTargetDuration} = manifest; + + if (!serverControl) { + return; + } + + const tag = '#EXT-X-SERVER-CONTROL'; + const hb = 'HOLD-BACK'; + const phb = 'PART-HOLD-BACK'; + const minTargetDuration = targetDuration && targetDuration * 3; + const minPartDuration = partTargetDuration && partTargetDuration * 2; + + if (targetDuration && !serverControl.hasOwnProperty(hb)) { + serverControl[hb] = minTargetDuration; + this.trigger('info', { + message: `${tag} defaulting ${hb} to targetDuration * 3 (${minTargetDuration}).` + }); + } + + if (minTargetDuration && serverControl[hb] < minTargetDuration) { + this.trigger('warn', { + message: `${tag} clamping ${hb} (${serverControl[hb]}) to targetDuration * 3 (${minTargetDuration})` + }); + serverControl[hb] = minTargetDuration; + } + + // default no part hold back to part target duration * 3 + if (partTargetDuration && !serverControl.hasOwnProperty(phb)) { + serverControl[phb] = partTargetDuration * 3; + this.trigger('info', { + message: `${tag} defaulting ${phb} to partTargetDuration * 3 (${serverControl[phb]}).` + }); + } + + // if part hold back is too small default it to part target duration * 2 + if (partTargetDuration && serverControl[phb] < (minPartDuration)) { + this.trigger('warn', { + message: `${tag} clamping ${phb} (${serverControl[phb]}) to partTargetDuration * 2 (${minPartDuration}).` + }); + + serverControl[phb] = minPartDuration; + } +}; + /** * A parser for M3U8 files. The current interpretation of the input is * exposed as a property `manifest` on parser objects. It's just two lines to @@ -353,6 +402,8 @@ export default class Parser extends Stream { return; } this.manifest.targetDuration = entry.duration; + + setHoldBack.call(this, this.manifest); }, totalduration() { if (!isFinite(entry.duration) || entry.duration < 0) { @@ -386,24 +437,114 @@ export default class Parser extends Stream { }, 'skip'() { this.manifest.skip = entry.attributes; + + if (!entry.attributes.hasOwnProperty('SKIPPED-SEGMENTS')) { + this.trigger('warn', { + message: '#EXT-X-SKIP lacks required attribute: SKIPPED-SEGMENTS' + }); + } }, 'part'() { this.manifest.parts = this.manifest.parts || []; this.manifest.parts.push(entry.attributes); + const missingAttributes = []; + + ['URI', 'DURATION'].forEach(function(k) { + if (!entry.attributes.hasOwnProperty(k)) { + missingAttributes.push(k); + } + }); + + if (missingAttributes.length) { + const index = this.manifest.parts.length - 1; + + this.trigger('warn', { + message: `#EXT-X-PART #${index} lacks required attribute(s): ${missingAttributes.join(', ')}` + }); + } + + if (this.manifest.renditionReports) { + this.manifest.renditionReports.forEach((r, i) => { + if (!r.hasOwnProperty('LAST-PART')) { + this.trigger('warn', { + message: `#EXT-X-RENDITION-REPORT #${i} lacks required attribute(s): LAST-PART` + }); + } + }); + } }, 'server-control'() { - this.manifest.serverControl = entry.attributes; + const attrs = entry.attributes; + + this.manifest.serverControl = attrs; + if (!attrs.hasOwnProperty('CAN-BLOCK-RELOAD')) { + this.manifest.serverControl['CAN-BLOCK-RELOAD'] = false; + this.trigger('info', { + message: '#EXT-X-SERVER-CONTROL defaulting CAN-BLOCK-RELOAD to false' + }); + } + setHoldBack.call(this, this.manifest); + + if (attrs['CAN-SKIP-DATERANGES'] && !attrs.hasOwnProperty('CAN-SKIP-UNTIL')) { + this.trigger('warn', { + message: '#EXT-X-SERVER-CONTROL lacks required attribute CAN-SKIP-UNTIL which is required when CAN-SKIP-DATERANGES is set' + }); + + } }, 'preload-hint'() { this.manifest.preloadHints = this.manifest.preloadHints || []; this.manifest.preloadHints.push(entry.attributes); + + const missingAttributes = []; + + ['TYPE', 'URI'].forEach(function(k) { + if (!entry.attributes.hasOwnProperty(k)) { + missingAttributes.push(k); + } + }); + + if (missingAttributes.length) { + const index = this.manifest.preloadHints.length - 1; + + this.trigger('warn', { + message: `#EXT-X-PRELOAD-HINT #${index} lacks required attribute(s): ${missingAttributes.join(', ')}` + }); + } }, 'rendition-report'() { this.manifest.renditionReports = this.manifest.renditionReports || []; this.manifest.renditionReports.push(entry.attributes); + const index = this.manifest.renditionReports.length - 1; + const missingAttributes = []; + const warning = `#EXT-X-RENDITION-REPORT #${index} lacks required attribute(s):`; + + ['LAST-MSN', 'URI'].forEach(function(k) { + if (!entry.attributes.hasOwnProperty(k)) { + missingAttributes.push(k); + } + }); + + if (this.manifest.parts && !entry.attributes['LAST-PART']) { + missingAttributes.push('LAST-PART'); + } + + if (missingAttributes.length) { + this.trigger('warn', {message: `${warning} ${missingAttributes.join(', ')}`}); + } }, 'part-inf'() { this.manifest.partInf = entry.attributes; + + if (!entry.attributes.hasOwnProperty('PART-TARGET')) { + this.trigger('warn', { + message: '#EXT-X-PART-INF lacks required attribute: PART-TARGET' + }); + } else { + this.manifest.partTargetDuration = entry.attributes['PART-TARGET']; + } + + setHoldBack.call(this, this.manifest); } })[entry.tagType] || noop).call(self); }, diff --git a/test/fixtures/integration/llhls.js b/test/fixtures/integration/llhls.js index 7e773c1..b34e32a 100644 --- a/test/fixtures/integration/llhls.js +++ b/test/fixtures/integration/llhls.js @@ -7,7 +7,7 @@ module.exports = { mediaSequence: 266, preloadHints: [ {TYPE: 'PART', URI: 'filePart273.3.mp4'}, - {TYPE: 'PART', URI: 'filePart273.4.mp4'} + {'TYPE': 'PART', 'URI': 'filePart273.4.mp4', 'BYTERANGE-LENGTH': 10, 'BYTERANGE-START': 0} ], renditionReports: [ {'LAST-MSN': 273, 'LAST-PART': 2, 'URI': '../1M/waitForMSN.php'}, @@ -16,6 +16,7 @@ module.exports = { partInf: { 'PART-TARGET': 0.33334 }, + partTargetDuration: 0.33334, parts: [ { DURATION: 0.33334, @@ -199,7 +200,7 @@ module.exports = { 'CAN-BLOCK-RELOAD': true, 'CAN-SKIP-UNTIL': 12, 'PART-HOLD-BACK': 1, - 'HOLD-BACK': 2 + 'HOLD-BACK': 12 }, targetDuration: 4 }; diff --git a/test/fixtures/integration/llhls.m3u8 b/test/fixtures/integration/llhls.m3u8 index dc208ee..2c9cc0d 100644 --- a/test/fixtures/integration/llhls.m3u8 +++ b/test/fixtures/integration/llhls.m3u8 @@ -2,7 +2,7 @@ # This Playlist is a response to: GET https://example.com/2M/waitForMSN.php?_HLS_msn=273&_HLS_part=2 #EXT-X-TARGETDURATION:4 #EXT-X-VERSION:6 -#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,CAN-SKIP-DATERANGES=YES,PART-HOLD-BACK=1.0,CAN-SKIP-UNTIL=12.0,HOLD-BACK=2.0 +#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,CAN-SKIP-DATERANGES=YES,PART-HOLD-BACK=1.0,CAN-SKIP-UNTIL=12.0,HOLD-BACK=12.0 #EXT-X-PART-INF:PART-TARGET=0.33334 #EXT-X-MEDIA-SEQUENCE:266 #EXT-X-PROGRAM-DATE-TIME:2019-02-14T02:13:36.106Z @@ -50,7 +50,7 @@ fileSequence272.mp4 #EXT-X-PART:DURATION=0.33334,URI="filePart273.1.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart273.2.mp4" #EXT-X-PRELOAD-HINT:TYPE=PART,URI="filePart273.3.mp4" -#EXT-X-PRELOAD-HINT:TYPE=PART,URI="filePart273.4.mp4" +#EXT-X-PRELOAD-HINT:TYPE=PART,URI="filePart273.4.mp4",BYTERANGE-START=0,BYTERANGE-LENGTH=10 #EXT-X-RENDITION-REPORT:URI="../1M/waitForMSN.php",LAST-MSN=273,LAST-PART=2 #EXT-X-RENDITION-REPORT:URI="../4M/waitForMSN.php",LAST-MSN=273,LAST-PART=1 diff --git a/test/fixtures/integration/llhlsDelta.js b/test/fixtures/integration/llhlsDelta.js index 018b0bd..acb8209 100644 --- a/test/fixtures/integration/llhlsDelta.js +++ b/test/fixtures/integration/llhlsDelta.js @@ -16,6 +16,7 @@ module.exports = { partInf: { 'PART-TARGET': 0.33334 }, + partTargetDuration: 0.33334, parts: [ { DURATION: 0.33334, @@ -172,7 +173,7 @@ module.exports = { 'CAN-BLOCK-RELOAD': true, 'CAN-SKIP-UNTIL': 12, 'PART-HOLD-BACK': 1, - 'HOLD-BACK': 2 + 'HOLD-BACK': 12 }, targetDuration: 4 }; diff --git a/test/fixtures/integration/llhlsDelta.m3u8 b/test/fixtures/integration/llhlsDelta.m3u8 index 85e3719..a95ee5d 100644 --- a/test/fixtures/integration/llhlsDelta.m3u8 +++ b/test/fixtures/integration/llhlsDelta.m3u8 @@ -2,7 +2,7 @@ # Following the example above, this Playlist is a response to: GET https://example.com/2M/waitForMSN.php?_HLS_msn=273&_HLS_part=3 &_HLS_skip=YES #EXT-X-TARGETDURATION:4 #EXT-X-VERSION:9 -#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,CAN-SKIP-DATERANGES=YES,PART-HOLD-BACK=1.0,CAN-SKIP-UNTIL=12.0,HOLD-BACK=2.0 +#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,CAN-SKIP-DATERANGES=YES,PART-HOLD-BACK=1.0,CAN-SKIP-UNTIL=12.0,HOLD-BACK=12.0 #EXT-X-PART-INF:PART-TARGET=0.33334 #EXT-X-MEDIA-SEQUENCE:266 #EXT-X-SKIP:SKIPPED-SEGMENTS=3,RECENTLY-REMOVED-DATERANGES=foo bar diff --git a/test/parser.test.js b/test/parser.test.js index dc1efae..ee4a990 100644 --- a/test/parser.test.js +++ b/test/parser.test.js @@ -383,31 +383,424 @@ QUnit.module('m3u8s', function(hooks) { ); }); - QUnit.module('warn/info'); + QUnit.module('warn/info', { + beforeEach() { + this.warnings = []; + this.infos = []; - QUnit.test('triggers warning for missing EXT-X-START TIME-OFFSET attribute', function(assert) { + this.parser.on('warn', (warn) => this.warnings.push(warn.message)); + this.parser.on('info', (info) => this.infos.push(info.message)); - const manifest = [ + } + }); + QUnit.test('warn when #EXT-X-TARGETDURATION is invalid', function(assert) { + this.parser.push([ + '#EXT-X-VERSION:3', + '#EXT-X-MEDIA-SEQUENCE:0', + '#EXT-X-DISCONTINUITY-SEQUENCE:0', + '#EXT-X-TARGETDURATION:foo', + '#EXTINF:10,', + 'media-00001.ts', + '#EXT-X-ENDLIST' + ].join('\n')); + this.parser.end(); + + const warnings = [ + 'ignoring invalid target duration: undefined' + ]; + + assert.deepEqual( + this.warnings, + warnings, + 'warnings as expected' + ); + + assert.deepEqual( + this.infos, + [], + 'info as expected' + ); + }); + + QUnit.test('warns when #EXT-X-START missing TIME-OFFSET attribute', function(assert) { + this.parser.push([ '#EXT-X-VERSION:3', + '#EXT-X-MEDIA-SEQUENCE:0', + '#EXT-X-DISCONTINUITY-SEQUENCE:0', '#EXT-X-TARGETDURATION:10', '#EXT-X-START:PRECISE=YES', '#EXTINF:10,', 'media-00001.ts', '#EXT-X-ENDLIST' - ].join('\n'); - let warning; - - this.parser.on('warn', function(warn) { - warning = warn; - }); - this.parser.push(manifest); + ].join('\n')); this.parser.end(); - assert.ok(warning, 'a warning was triggered'); - assert.ok((/ignoring start/).test(warning.message), 'message is about start tag'); + assert.deepEqual( + this.warnings, + ['ignoring start declaration without appropriate attribute list'], + 'warnings as expected' + ); + + assert.deepEqual( + this.infos, + [], + 'info as expected' + ); + assert.strictEqual(typeof this.parser.manifest.start, 'undefined', 'does not parse start'); }); + QUnit.test('warning when #EXT-X-SKIP missing SKIPPED-SEGMENTS attribute', function(assert) { + this.parser.push([ + '#EXT-X-VERSION:3', + '#EXT-X-MEDIA-SEQUENCE:0', + '#EXT-X-DISCONTINUITY-SEQUENCE:0', + '#EXT-X-TARGETDURATION:10', + '#EXT-X-SKIP:foo=bar', + '#EXTINF:10,', + 'media-00001.ts', + '#EXT-X-ENDLIST' + ].join('\n')); + this.parser.end(); + + assert.deepEqual( + this.warnings, + ['#EXT-X-SKIP lacks required attribute: SKIPPED-SEGMENTS'], + 'warnings as expected' + ); + + assert.deepEqual( + this.infos, + [], + 'info as expected' + ); + }); + + QUnit.test('warns when #EXT-X-PART missing URI/DURATION attributes', function(assert) { + this.parser.push([ + '#EXT-X-VERSION:3', + '#EXT-X-MEDIA-SEQUENCE:0', + '#EXT-X-DISCONTINUITY-SEQUENCE:0', + '#EXT-X-TARGETDURATION:10', + '#EXT-X-PART:DURATION=1', + '#EXT-X-PART:URI=2', + '#EXT-X-PART:foo=bar', + '#EXTINF:10,', + 'media-00001.ts', + '#EXT-X-ENDLIST' + ].join('\n')); + this.parser.end(); + + const warnings = [ + '#EXT-X-PART #0 lacks required attribute(s): URI', + '#EXT-X-PART #1 lacks required attribute(s): DURATION', + '#EXT-X-PART #2 lacks required attribute(s): URI, DURATION' + ]; + + assert.deepEqual( + this.warnings, + warnings, + 'warnings as expected' + ); + + assert.deepEqual( + this.infos, + [], + 'info as expected' + ); + }); + + QUnit.test('warns when #EXT-X-PRELOAD-HINT missing TYPE/URI attribute', function(assert) { + this.parser.push([ + '#EXT-X-VERSION:3', + '#EXT-X-MEDIA-SEQUENCE:0', + '#EXT-X-DISCONTINUITY-SEQUENCE:0', + '#EXT-X-TARGETDURATION:10', + '#EXT-X-PRELOAD-HINT:TYPE=foo', + '#EXT-X-PRELOAD-HINT:URI=foo', + '#EXT-X-PRELOAD-HINT:foo=bar', + '#EXTINF:10,', + 'media-00001.ts', + '#EXT-X-ENDLIST' + ].join('\n')); + this.parser.end(); + + const warnings = [ + '#EXT-X-PRELOAD-HINT #0 lacks required attribute(s): URI', + '#EXT-X-PRELOAD-HINT #1 lacks required attribute(s): TYPE', + '#EXT-X-PRELOAD-HINT #2 lacks required attribute(s): TYPE, URI' + ]; + + assert.deepEqual( + this.warnings, + warnings, + 'warnings as expected' + ); + + assert.deepEqual( + this.infos, + [], + 'info as expected' + ); + }); + + QUnit.test('warn when #EXT-X-RENDITION-REPORT missing LAST-MSN/URI attribute', function(assert) { + this.parser.push([ + '#EXT-X-VERSION:3', + '#EXT-X-MEDIA-SEQUENCE:0', + '#EXT-X-DISCONTINUITY-SEQUENCE:0', + '#EXT-X-TARGETDURATION:10', + '#EXT-X-RENDITION-REPORT:URI=foo', + '#EXT-X-RENDITION-REPORT:LAST-MSN=2', + '#EXT-X-RENDITION-REPORT:foo=bar', + '#EXTINF:10,', + 'media-00001.ts', + '#EXT-X-ENDLIST' + ].join('\n')); + this.parser.end(); + + const warnings = [ + '#EXT-X-RENDITION-REPORT #0 lacks required attribute(s): LAST-MSN', + '#EXT-X-RENDITION-REPORT #1 lacks required attribute(s): URI', + '#EXT-X-RENDITION-REPORT #2 lacks required attribute(s): LAST-MSN, URI' + ]; + + assert.deepEqual( + this.warnings, + warnings, + 'warnings as expected' + ); + + assert.deepEqual( + this.infos, + [], + 'info as expected' + ); + }); + + QUnit.test('warns when #EXT-X-RENDITION-REPORT missing LAST-PART attribute with parts', function(assert) { + this.parser.push([ + '#EXT-X-VERSION:3', + '#EXT-X-MEDIA-SEQUENCE:0', + '#EXT-X-DISCONTINUITY-SEQUENCE:0', + '#EXT-X-TARGETDURATION:10', + '#EXT-X-RENDITION-REPORT:URI=foo,LAST-MSN=4', + '#EXT-X-PART:URI=foo,DURATION=10', + '#EXT-X-RENDITION-REPORT:URI=foo,LAST-MSN=4', + '#EXTINF:10,', + 'media-00001.ts', + '#EXT-X-ENDLIST' + ].join('\n')); + this.parser.end(); + + const warnings = [ + '#EXT-X-RENDITION-REPORT #0 lacks required attribute(s): LAST-PART', + '#EXT-X-RENDITION-REPORT #1 lacks required attribute(s): LAST-PART' + ]; + + assert.deepEqual( + this.warnings, + warnings, + 'warnings as expected' + ); + + assert.deepEqual( + this.infos, + [], + 'info as expected' + ); + }); + + QUnit.test('warns when #EXT-X-PART-INF missing PART-TARGET attribute', function(assert) { + this.parser.push([ + '#EXT-X-VERSION:3', + '#EXT-X-MEDIA-SEQUENCE:0', + '#EXT-X-DISCONTINUITY-SEQUENCE:0', + '#EXT-X-TARGETDURATION:10', + '#EXT-X-PART-INF:URI=foo', + '#EXTINF:10,', + 'media-00001.ts', + '#EXT-X-ENDLIST' + ].join('\n')); + this.parser.end(); + + const warnings = [ + '#EXT-X-PART-INF lacks required attribute: PART-TARGET' + ]; + + assert.deepEqual( + this.warnings, + warnings, + 'warnings as expected' + ); + + assert.deepEqual( + this.infos, + [], + 'info as expected' + ); + }); + + QUnit.test('warns when #EXT-X-SERVER-CONTROL missing CAN-SKIP-UNTIL with CAN-SKIP-DATERANGES attribute', function(assert) { + this.parser.push([ + '#EXT-X-VERSION:3', + '#EXT-X-MEDIA-SEQUENCE:0', + '#EXT-X-DISCONTINUITY-SEQUENCE:0', + '#EXT-X-TARGETDURATION:10', + '#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=NO,HOLD-BACK=30,CAN-SKIP-DATERANGES=YES', + '#EXTINF:10,', + 'media-00001.ts', + '#EXT-X-ENDLIST' + ].join('\n')); + this.parser.end(); + + const warnings = [ + '#EXT-X-SERVER-CONTROL lacks required attribute CAN-SKIP-UNTIL which is required when CAN-SKIP-DATERANGES is set' + ]; + + assert.deepEqual( + this.warnings, + warnings, + 'warnings as expected' + ); + + assert.deepEqual( + this.infos, + [], + 'info as expected' + ); + }); + + QUnit.test('warn when #EXT-X-SERVER-CONTROL HOLD-BACK and PART-HOLD-BACK too low', function(assert) { + this.parser.push([ + '#EXT-X-VERSION:3', + '#EXT-X-MEDIA-SEQUENCE:0', + '#EXT-X-DISCONTINUITY-SEQUENCE:0', + '#EXT-X-TARGETDURATION:10', + '#EXT-X-PART-INF:PART-TARGET=1', + '#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,HOLD-BACK=1,PART-HOLD-BACK=0.5', + '#EXTINF:10,', + 'media-00001.ts', + '#EXT-X-ENDLIST' + ].join('\n')); + this.parser.end(); + + const warnings = [ + '#EXT-X-SERVER-CONTROL clamping HOLD-BACK (1) to targetDuration * 3 (30)', + '#EXT-X-SERVER-CONTROL clamping PART-HOLD-BACK (0.5) to partTargetDuration * 2 (2).' + ]; + + assert.deepEqual( + this.warnings, + warnings, + 'warnings as expected' + ); + + assert.deepEqual( + this.infos, + [], + 'info as expected' + ); + }); + + QUnit.test('warn when #EXT-X-SERVER-CONTROL before target durations HOLD-BACK/PART-HOLD-BACK too low', function(assert) { + this.parser.push([ + '#EXT-X-VERSION:3', + '#EXT-X-MEDIA-SEQUENCE:0', + '#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,HOLD-BACK=1,PART-HOLD-BACK=0.5', + '#EXT-X-DISCONTINUITY-SEQUENCE:0', + '#EXT-X-TARGETDURATION:10', + '#EXT-X-PART-INF:PART-TARGET=1', + '#EXTINF:10,', + 'media-00001.ts', + '#EXT-X-ENDLIST' + ].join('\n')); + this.parser.end(); + + const warnings = [ + '#EXT-X-SERVER-CONTROL clamping HOLD-BACK (1) to targetDuration * 3 (30)', + '#EXT-X-SERVER-CONTROL clamping PART-HOLD-BACK (0.5) to partTargetDuration * 2 (2).' + ]; + + assert.deepEqual( + this.warnings, + warnings, + 'warnings as expected' + ); + + assert.deepEqual( + this.infos, + [], + 'info as expected' + ); + }); + + QUnit.test('info when #EXT-X-SERVER-CONTROL sets defaults', function(assert) { + this.parser.push([ + '#EXT-X-VERSION:3', + '#EXT-X-MEDIA-SEQUENCE:0', + '#EXT-X-DISCONTINUITY-SEQUENCE:0', + '#EXT-X-TARGETDURATION:10', + '#EXT-X-PART-INF:PART-TARGET=1', + '#EXT-X-SERVER-CONTROL:foo=bar', + '#EXTINF:10,', + 'media-00001.ts', + '#EXT-X-ENDLIST' + ].join('\n')); + this.parser.end(); + + const infos = [ + '#EXT-X-SERVER-CONTROL defaulting CAN-BLOCK-RELOAD to false', + '#EXT-X-SERVER-CONTROL defaulting HOLD-BACK to targetDuration * 3 (30).', + '#EXT-X-SERVER-CONTROL defaulting PART-HOLD-BACK to partTargetDuration * 3 (3).' + ]; + + assert.deepEqual( + this.warnings, + [], + 'warnings as expected' + ); + + assert.deepEqual( + this.infos, + infos, + 'info as expected' + ); + }); + + QUnit.test('info when #EXT-X-SERVER-CONTROL before target durations and sets defaults', function(assert) { + this.parser.push([ + '#EXT-X-VERSION:3', + '#EXT-X-MEDIA-SEQUENCE:0', + '#EXT-X-DISCONTINUITY-SEQUENCE:0', + '#EXT-X-SERVER-CONTROL:foo=bar', + '#EXT-X-TARGETDURATION:10', + '#EXT-X-PART-INF:PART-TARGET=1', + '#EXTINF:10,', + 'media-00001.ts', + '#EXT-X-ENDLIST' + ].join('\n')); + this.parser.end(); + + const infos = [ + '#EXT-X-SERVER-CONTROL defaulting CAN-BLOCK-RELOAD to false', + '#EXT-X-SERVER-CONTROL defaulting HOLD-BACK to targetDuration * 3 (30).', + '#EXT-X-SERVER-CONTROL defaulting PART-HOLD-BACK to partTargetDuration * 3 (3).' + ]; + + assert.deepEqual( + this.warnings, + [], + 'warnings as expected' + ); + + assert.deepEqual( + this.infos, + infos, + 'info as expected' + ); + }); + QUnit.module('integration'); for (const key in testDataExpected) {