├── .eslintrc.yml ├── .github └── workflows │ ├── lint.yml │ ├── nodejs.yml │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── example ├── discord.js ├── live.js └── progress.js ├── package-lock.json ├── package.json ├── src ├── dash-mpd-parser.ts ├── index.ts ├── m3u8-parser.ts ├── parse-time.ts ├── parser.ts └── queue.ts ├── test ├── .eslintrc.yml ├── .gitattributes ├── dash-mpd-parser-test.ts ├── m3u8-parser-test.ts ├── main-test.ts ├── parse-time-test.ts ├── playlists │ ├── encrypted.m3u8 │ ├── example.mpd │ ├── facebook.mpd │ ├── live-1.1.m3u8 │ ├── live-1.2.m3u8 │ ├── live-2.1.m3u8 │ ├── live-2.2.m3u8 │ ├── main.mp4 │ ├── master.m3u8 │ ├── multi-representation.mpd │ ├── segment-template-2.mpd │ ├── segment-template.mpd │ ├── simple.m3u8 │ ├── simple.mpd │ ├── simple_relative.m3u8 │ ├── twitch-1.1.m3u8 │ ├── x-byterange-1.m3u8 │ ├── x-map-1.m3u8 │ ├── x-map-2.m3u8 │ ├── x-map-3.m3u8 │ ├── youtube-live-1.1.m3u8 │ └── youtube-live-1.2.m3u8 └── queue-test.ts ├── tsconfig.build.json └── tsconfig.json /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | es6: true 3 | node: true 4 | mocha: false 5 | extends: 6 | - 'eslint:recommended' 7 | - 'plugin:@typescript-eslint/eslint-recommended' 8 | - 'plugin:@typescript-eslint/recommended' 9 | parser: '@typescript-eslint/parser' 10 | parserOptions: 11 | ecmaVersion: 2017 12 | plugins: 13 | - '@typescript-eslint' 14 | rules: 15 | no-await-in-loop: off 16 | no-compare-neg-zero: error 17 | no-extra-parens: 18 | - warn 19 | - all 20 | - nestedBinaryExpressions: false 21 | no-template-curly-in-string: error 22 | no-unsafe-negation: error 23 | valid-jsdoc: 24 | - warn 25 | - prefer: 26 | arg: param 27 | return: returns 28 | preferType: 29 | Boolean: boolean 30 | Number: number 31 | object: Object 32 | String: string 33 | requireReturn: false 34 | requireReturnType: true 35 | requireParamDescription: false 36 | requireReturnDescription: false 37 | requireParamType: true 38 | accessor-pairs: warn 39 | array-callback-return: error 40 | complexity: 41 | - off 42 | - max: 25 43 | consistent-return: warn 44 | curly: 45 | - error 46 | - multi-line 47 | - consistent 48 | dot-location: 49 | - error 50 | - property 51 | dot-notation: error 52 | eqeqeq: error 53 | no-console: 54 | - error 55 | - allow: 56 | - warn 57 | no-empty-function: error 58 | no-floating-decimal: error 59 | no-implied-eval: error 60 | no-invalid-this: error 61 | no-lone-blocks: error 62 | no-multi-spaces: error 63 | no-new-func: error 64 | no-new-wrappers: error 65 | no-new: error 66 | no-octal-escape: error 67 | no-return-assign: off 68 | no-return-await: error 69 | no-self-compare: error 70 | no-sequences: error 71 | no-throw-literal: error 72 | no-unmodified-loop-condition: error 73 | no-unused-expressions: error 74 | no-useless-call: error 75 | no-useless-concat: error 76 | no-useless-escape: error 77 | no-useless-return: error 78 | no-void: error 79 | no-warning-comments: warn 80 | prefer-promise-reject-errors: error 81 | require-await: warn 82 | wrap-iife: error 83 | yoda: error 84 | no-label-var: error 85 | no-shadow: error 86 | no-undef-init: error 87 | callback-return: off 88 | handle-callback-err: error 89 | no-mixed-requires: error 90 | no-new-require: error 91 | no-path-concat: error 92 | array-bracket-spacing: error 93 | block-spacing: error 94 | brace-style: 95 | - error 96 | - 1tbs 97 | - allowSingleLine: true 98 | capitalized-comments: 99 | - error 100 | - always 101 | - ignoreConsecutiveComments: true 102 | comma-dangle: 103 | - error 104 | - always-multiline 105 | comma-spacing: error 106 | comma-style: error 107 | computed-property-spacing: error 108 | consistent-this: 109 | - error 110 | - "$this" 111 | eol-last: error 112 | func-names: error 113 | func-name-matching: error 114 | func-style: 115 | - error 116 | - declaration 117 | - allowArrowFunctions: true 118 | indent: 119 | - error 120 | - 2 121 | - SwitchCase: 1 122 | key-spacing: error 123 | keyword-spacing: error 124 | max-depth: error 125 | max-len: 126 | - error 127 | - 120 128 | - 2 129 | max-nested-callbacks: 130 | - error 131 | - max: 4 132 | max-statements-per-line: 133 | - error 134 | - max: 2 135 | new-cap: off 136 | newline-per-chained-call: 137 | - error 138 | - ignoreChainWithDepth: 3 139 | no-array-constructor: error 140 | no-inline-comments: error 141 | no-lonely-if: error 142 | no-mixed-operators: error 143 | no-multiple-empty-lines: 144 | - error 145 | - max: 2 146 | maxEOF: 1 147 | maxBOF: 0 148 | no-new-object: error 149 | no-spaced-func: error 150 | no-trailing-spaces: error 151 | no-unneeded-ternary: error 152 | no-whitespace-before-property: error 153 | nonblock-statement-body-position: error 154 | object-curly-spacing: 155 | - error 156 | - always 157 | operator-assignment: error 158 | operator-linebreak: 159 | - error 160 | - after 161 | padded-blocks: 162 | - error 163 | - never 164 | quote-props: 165 | - error 166 | - as-needed 167 | quotes: 168 | - error 169 | - single 170 | - avoidEscape: true 171 | allowTemplateLiterals: true 172 | semi-spacing: error 173 | semi: error 174 | space-before-blocks: error 175 | space-before-function-paren: 176 | - error 177 | - never 178 | space-in-parens: error 179 | space-infix-ops: error 180 | space-unary-ops: error 181 | spaced-comment: error 182 | template-tag-spacing: error 183 | unicode-bom: error 184 | arrow-body-style: error 185 | arrow-parens: 186 | - error 187 | - as-needed 188 | arrow-spacing: error 189 | no-duplicate-imports: error 190 | no-useless-computed-key: error 191 | no-useless-constructor: error 192 | prefer-arrow-callback: error 193 | prefer-numeric-literals: error 194 | prefer-rest-params: error 195 | prefer-spread: error 196 | prefer-template: error 197 | rest-spread-spacing: error 198 | template-curly-spacing: error 199 | yield-star-spacing: error 200 | no-var: error 201 | prefer-const: off 202 | '@typescript-eslint/no-var-requires': off 203 | '@typescript-eslint/no-use-before-define': off 204 | '@typescript-eslint/explicit-function-return-type': off 205 | '@typescript-eslint/camelcase': off 206 | '@typescript-eslint/class-name-casing': off 207 | '@typescript-eslint/no-explicit-any': off 208 | '@typescript-eslint/no-namespace': off 209 | '@typescript-eslint/ban-ts-ignore': off 210 | '@typescript-eslint/no-unused-vars': off 211 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | eslint: 7 | name: Lint 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v1 11 | 12 | - uses: actions/setup-node@v1 13 | 14 | - name: npm install, build, and lint 15 | run: | 16 | npm install 17 | npm run build --if-present 18 | npm run-script lint 19 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | name: Test on node ${{ matrix.node-version }} and ${{ matrix.os }} 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | matrix: 11 | node-version: [12.x, 14.x, 16.x] 12 | os: [ubuntu-latest] 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | 22 | - name: npm install, build, and test 23 | run: | 24 | npm install 25 | npm run build --if-present 26 | npm test 27 | 28 | - uses: codecov/codecov-action@v1.0.3 29 | with: 30 | token: ${{ secrets.CODECOV_TOKEN }} 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | release: 8 | name: Release 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | with: 14 | fetch-depth: 0 15 | - name: Setup Node.js 16 | uses: actions/setup-node@v2 17 | - name: Install dependencies 18 | run: npm ci 19 | - name: Release 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 23 | run: npx semantic-release 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | .nyc_output 3 | node_modules 4 | dist 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (C) 2017 by fent 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # node-m3u8stream 2 | 3 | Reads segments from a [m3u8 playlist][1] or [DASH MPD file][2] into a consumable stream. 4 | 5 | [1]: https://tools.ietf.org/html/draft-pantos-http-live-streaming-20 6 | [2]: https://dashif.org/docs/DASH-IF-IOP-v4.2-clean.pdf 7 | 8 | ![Depfu](https://img.shields.io/depfu/fent/node-m3u8stream) 9 | [![codecov](https://codecov.io/gh/fent/node-m3u8stream/branch/master/graph/badge.svg)](https://codecov.io/gh/fent/node-m3u8stream) 10 | 11 | 12 | # Usage 13 | 14 | ```js 15 | const fs = require('fs'); 16 | const m3u8stream = require('m3u8stream') 17 | 18 | m3u8stream('http://somesite.com/link/to/the/playlist.m3u8') 19 | .pipe(fs.createWriteStream('videofile.mp4')); 20 | ``` 21 | 22 | 23 | # API 24 | 25 | ### m3u8stream(url, [options]) 26 | 27 | Creates a readable stream of binary media data. `options` can have the following 28 | 29 | * `begin` - Where to begin playing the video. Accepts an absolute unix timestamp or date and a relative time in the formats `1:23:45.123` and `1m2s`. 30 | * `liveBuffer` - How much buffer in milliseconds to have for live streams. Default is `20000`. 31 | * `chunkReadahead` - How many chunks to preload ahead. Default is `3`. 32 | * `highWaterMark` - How much of the download to buffer into the stream. See [node's docs](https://nodejs.org/api/stream.html#stream_constructor_new_stream_writable_options) for more. Note that the actual amount buffered can be higher since each chunk request maintains its own buffer. 33 | * `requestOptions` - Any options you want to pass to [miniget](https://github.com/fent/node-miniget), such as `headers`. 34 | * `parser` - Either "m3u8" or "dash-mpd". Defaults to guessing based on the playlist url ending in `.m3u8` or `.mpd`. 35 | * `id` - For playlist containing multiple media options. If not given, the first representation will be picked. 36 | 37 | ### Stream#end() 38 | 39 | If called, stops requesting segments, and refreshing the playlist. 40 | 41 | #### Event: progress 42 | * `Object` - Current segment with the following fields, 43 | - `number` - num 44 | - `number` - size 45 | - `number` - duration 46 | - `string` - url 47 | * `number` - Total number of segments. 48 | * `number` - Bytes downloaded up to this point. 49 | 50 | For static non-live playlists, emitted each time a segment has finished downloading. Since total download size is unknown until all segment endpoints are hit, progress is calculated based on how many segments are available. 51 | 52 | #### miniget events 53 | 54 | All [miniget events](https://github.com/fent/node-miniget#event-redirect) are forwarded and can be listened to from the returned stream. 55 | 56 | ### m3u8stream.parseTimestamp(time) 57 | 58 | Converts human friendly time to milliseconds. Supports the format 59 | 00:00:00.000 for hours, minutes, seconds, and milliseconds respectively. 60 | And 0ms, 0s, 0m, 0h, and together 1m1s. 61 | 62 | * `time` - A string (or number) giving the user-readable input data 63 | 64 | ### Limitations 65 | 66 | Currently, it does not support [encrypted media segments](https://tools.ietf.org/html/draft-pantos-http-live-streaming-20#section-4.3.2.4). This is because the sites where this was tested on and intended for, YouTube and Twitch, don't use it. 67 | 68 | This does not parse master playlists, only media playlists. If you want to parse a master playlist to get links to media playlists, you can try the [m3u8 module](https://github.com/tedconf/node-m3u8). 69 | 70 | 71 | # Install 72 | 73 | npm install m3u8stream 74 | 75 | 76 | # Tests 77 | Tests are written with [mocha](https://mochajs.org) 78 | 79 | ```bash 80 | npm test 81 | ``` 82 | -------------------------------------------------------------------------------- /example/discord.js: -------------------------------------------------------------------------------- 1 | const Discord = require('discord.js'); 2 | const m3u8stream = require('..'); 3 | 4 | const playlist = process.argv[2] || ''; 5 | if (!playlist) { 6 | const path = require('path'); 7 | const filepath = path.relative(process.cwd(), __filename); 8 | console.error('Must provide link to playlist'); 9 | console.error('usage: node ' + filepath + ' [playlist]'); 10 | 11 | } else { 12 | const client = new Discord.Client(); 13 | client.login(' Y o u r B o t T o k e n '); 14 | 15 | client.on('message', message => { 16 | if (message.content.startsWith('++play')) { 17 | const voiceChannel = message.member.voiceChannel; 18 | if (!voiceChannel) { 19 | return message.reply('Please be in a voice channel first!'); 20 | } 21 | voiceChannel.join() 22 | .then(connnection => { 23 | let stream = m3u8stream(playlist); 24 | const dispatcher = connnection.playStream(stream); 25 | dispatcher.on('end', () => { 26 | voiceChannel.leave(); 27 | }); 28 | }); 29 | } 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /example/live.js: -------------------------------------------------------------------------------- 1 | const m3u8stream = require('..'); 2 | 3 | const playlist = process.argv[2]; 4 | if (!playlist) { 5 | const path = require('path'); 6 | const filepath = path.relative(process.cwd(), __filename); 7 | console.error('Must provide link to playlist'); 8 | console.error('usage: node ' + filepath + ' '); 9 | } else { 10 | m3u8stream(playlist).pipe(process.stdout); 11 | } 12 | -------------------------------------------------------------------------------- /example/progress.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const readline = require('readline'); 3 | const m3u8stream = require('..'); 4 | 5 | const playlist = process.argv[2]; 6 | if (!playlist) { 7 | const path = require('path'); 8 | const filepath = path.relative(process.cwd(), __filename); 9 | console.error('Must provide link to playlist'); 10 | console.error('usage: node ' + filepath + ' '); 11 | } else { 12 | const stream = m3u8stream(playlist); 13 | stream.pipe(fs.createWriteStream('media.mp4')); 14 | stream.on('progress', (segment, totalSegments, downloaded) => { 15 | readline.cursorTo(process.stdout, 0); 16 | process.stdout.write( 17 | `${segment.num} of ${totalSegments} segments ` + 18 | `(${(segment.num / totalSegments * 100).toFixed(2)}%) ` + 19 | `${(downloaded / 1024 / 1024).toFixed(2)}MB downloaded`); 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "m3u8stream", 3 | "description": "Reads segments from a m3u8 or dash playlist into a consumable stream.", 4 | "keywords": [ 5 | "m3u8", 6 | "hls", 7 | "dash", 8 | "live", 9 | "playlist", 10 | "segments", 11 | "stream" 12 | ], 13 | "version": "0.8.3", 14 | "repository": { 15 | "type": "git", 16 | "url": "git://github.com/fent/node-m3u8stream.git" 17 | }, 18 | "author": "fent (https://github.com/fent)", 19 | "main": "./dist/index.js", 20 | "files": [ 21 | "dist" 22 | ], 23 | "scripts": { 24 | "prepare": "tsc -p tsconfig.build.json", 25 | "build": "tsc -p tsconfig.build.json", 26 | "test": "nyc --extension .ts --reporter=lcov --reporter=text-summary npm run test:unit", 27 | "test:unit": "mocha -- --require ts-node/register test/*-test.ts", 28 | "lint": "eslint ./src ./test", 29 | "lint:fix": "eslint --fix ./src ./test" 30 | }, 31 | "dependencies": { 32 | "miniget": "^4.2.2", 33 | "sax": "^1.2.4" 34 | }, 35 | "devDependencies": { 36 | "@types/mocha": "^7.0.0", 37 | "@types/node": "^17.0.8", 38 | "@types/sax": "^1.0.1", 39 | "@types/sinon": "^9.0.8", 40 | "@typescript-eslint/eslint-plugin": "^4.8.2", 41 | "@typescript-eslint/parser": "^4.8.2", 42 | "eslint": "^7.14.0", 43 | "mocha": "^7.0.1", 44 | "nock": "^13.0.5", 45 | "nyc": "^15.0.0", 46 | "sinon": "^9.2.0", 47 | "ts-node": "^9.0.0", 48 | "typescript": "^4.0.5" 49 | }, 50 | "engines": { 51 | "node": ">=12" 52 | }, 53 | "license": "MIT" 54 | } 55 | -------------------------------------------------------------------------------- /src/dash-mpd-parser.ts: -------------------------------------------------------------------------------- 1 | import { Writable } from 'stream'; 2 | import sax from 'sax'; 3 | import { durationStr } from './parse-time'; 4 | import { Parser } from './parser'; 5 | 6 | 7 | /** 8 | * A wrapper around sax that emits segments. 9 | */ 10 | export default class DashMPDParser extends Writable implements Parser { 11 | private _parser: Writable; 12 | 13 | constructor(targetID?: string) { 14 | super(); 15 | this._parser = sax.createStream(false, { lowercase: true }); 16 | this._parser.on('error', this.destroy.bind(this)); 17 | 18 | let lastTag: string | null; 19 | let currtime = 0; 20 | let seq = 0; 21 | let segmentTemplate: { initialization?: string; media: string }; 22 | let timescale: number, offset: number, duration: number, baseURL: string[]; 23 | let timeline: { 24 | duration: number; 25 | repeat: number; 26 | time: number; 27 | }[] = []; 28 | let getSegments = false; 29 | let gotSegments = false; 30 | let isStatic: boolean; 31 | let treeLevel: number; 32 | let periodStart: number; 33 | 34 | const tmpl = (str: string): string => { 35 | const context: { [key: string]: string | number | undefined } = { 36 | RepresentationID: targetID, 37 | Number: seq, 38 | Time: currtime, 39 | }; 40 | return str.replace(/\$(\w+)\$/g, (m, p1) => `${context[p1]}`); 41 | }; 42 | 43 | this._parser.on('opentag', node => { 44 | switch (node.name) { 45 | case 'mpd': 46 | currtime = 47 | node.attributes.availabilitystarttime ? 48 | new Date(node.attributes.availabilitystarttime).getTime() : 0; 49 | isStatic = node.attributes.type !== 'dynamic'; 50 | break; 51 | case 'period': 52 | // Reset everything on tag. 53 | seq = 0; 54 | timescale = 1000; 55 | duration = 0; 56 | offset = 0; 57 | baseURL = []; 58 | treeLevel = 0; 59 | periodStart = durationStr(node.attributes.start) || 0; 60 | break; 61 | case 'segmentlist': 62 | seq = parseInt(node.attributes.startnumber) || seq; 63 | timescale = parseInt(node.attributes.timescale) || timescale; 64 | duration = parseInt(node.attributes.duration) || duration; 65 | offset = parseInt(node.attributes.presentationtimeoffset) || offset; 66 | break; 67 | case 'segmenttemplate': 68 | segmentTemplate = node.attributes; 69 | seq = parseInt(node.attributes.startnumber) || seq; 70 | timescale = parseInt(node.attributes.timescale) || timescale; 71 | break; 72 | case 'segmenttimeline': 73 | case 'baseurl': 74 | lastTag = node.name; 75 | break; 76 | case 's': 77 | timeline.push({ 78 | duration: parseInt(node.attributes.d), 79 | repeat: parseInt(node.attributes.r), 80 | time: parseInt(node.attributes.t), 81 | }); 82 | break; 83 | case 'adaptationset': 84 | case 'representation': 85 | treeLevel++; 86 | if (!targetID) { 87 | targetID = node.attributes.id; 88 | } 89 | getSegments = node.attributes.id === `${targetID}`; 90 | if (getSegments) { 91 | if (periodStart) { 92 | currtime += periodStart; 93 | } 94 | if (offset) { 95 | currtime -= offset / timescale * 1000; 96 | } 97 | this.emit('starttime', currtime); 98 | } 99 | break; 100 | case 'initialization': 101 | if (getSegments) { 102 | this.emit('item', { 103 | url: baseURL.filter(s => !!s).join('') + node.attributes.sourceurl, 104 | seq: seq, 105 | init: true, 106 | duration: 0, 107 | }); 108 | } 109 | break; 110 | case 'segmenturl': 111 | if (getSegments) { 112 | gotSegments = true; 113 | let tl = timeline.shift(); 114 | let segmentDuration = (tl?.duration || duration) / timescale * 1000; 115 | this.emit('item', { 116 | url: baseURL.filter(s => !!s).join('') + node.attributes.media, 117 | seq: seq++, 118 | duration: segmentDuration, 119 | }); 120 | currtime += segmentDuration; 121 | } 122 | break; 123 | } 124 | }); 125 | 126 | const onEnd = (): void => { 127 | if (isStatic) { this.emit('endlist'); } 128 | if (!getSegments) { 129 | this.destroy(Error(`Representation '${targetID}' not found`)); 130 | } else { 131 | this.emit('end'); 132 | } 133 | }; 134 | 135 | this._parser.on('closetag', tagName => { 136 | switch (tagName) { 137 | case 'adaptationset': 138 | case 'representation': 139 | treeLevel--; 140 | if (segmentTemplate && timeline.length) { 141 | gotSegments = true; 142 | if (segmentTemplate.initialization) { 143 | this.emit('item', { 144 | url: baseURL.filter(s => !!s).join('') + 145 | tmpl(segmentTemplate.initialization), 146 | seq: seq, 147 | init: true, 148 | duration: 0, 149 | }); 150 | } 151 | for (let { duration: itemDuration, repeat, time } of timeline) { 152 | itemDuration = itemDuration / timescale * 1000; 153 | repeat = repeat || 1; 154 | currtime = time || currtime; 155 | for (let i = 0; i < repeat; i++) { 156 | this.emit('item', { 157 | url: baseURL.filter(s => !!s).join('') + 158 | tmpl(segmentTemplate.media), 159 | seq: seq++, 160 | duration: itemDuration, 161 | }); 162 | currtime += itemDuration; 163 | } 164 | } 165 | } 166 | if (gotSegments) { 167 | this.emit('endearly'); 168 | onEnd(); 169 | this._parser.removeAllListeners(); 170 | this.removeAllListeners('finish'); 171 | } 172 | break; 173 | } 174 | }); 175 | 176 | this._parser.on('text', text => { 177 | if (lastTag === 'baseurl') { 178 | baseURL[treeLevel] = text; 179 | lastTag = null; 180 | } 181 | }); 182 | 183 | this.on('finish', onEnd); 184 | } 185 | 186 | _write(chunk: Buffer, encoding: string, callback: () => void): void { 187 | this._parser.write(chunk); 188 | callback(); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { PassThrough } from 'stream'; 2 | import miniget from 'miniget'; 3 | import m3u8Parser from './m3u8-parser'; 4 | import DashMPDParser from './dash-mpd-parser'; 5 | import { Callback, Queue } from './queue'; 6 | import { humanStr } from './parse-time'; 7 | import { Item } from './parser'; 8 | 9 | 10 | namespace m3u8stream { 11 | export interface Options { 12 | begin?: number | string; 13 | liveBuffer?: number; 14 | chunkReadahead?: number; 15 | highWaterMark?: number; 16 | requestOptions?: miniget.Options; 17 | parser?: 'm3u8' | 'dash-mpd'; 18 | id?: string; 19 | } 20 | 21 | export interface Progress { 22 | num: number; 23 | size: number; 24 | duration: number; 25 | url: string; 26 | } 27 | export interface Stream extends PassThrough { 28 | end: () => this; 29 | on(event: 'progress', listener: (progress: Progress, totalSegments: number, downloadedBytes: number) => void): this; 30 | on(event: string | symbol, listener: (...args: any) => void): this; 31 | } 32 | 33 | export interface m3u8streamFunc { 34 | (playlistURL: string, options?: m3u8stream.Options): Stream; 35 | parseTimestamp(time: number | string): number; 36 | } 37 | } 38 | 39 | interface TimedItem extends Item { 40 | time: number; 41 | } 42 | 43 | const supportedParsers = { 44 | m3u8: m3u8Parser, 45 | 'dash-mpd': DashMPDParser, 46 | }; 47 | 48 | let m3u8stream = ((playlistURL: string, options: m3u8stream.Options = {}): m3u8stream.Stream => { 49 | const stream = new PassThrough({ highWaterMark: options.highWaterMark }) as m3u8stream.Stream; 50 | const chunkReadahead = options.chunkReadahead || 3; 51 | // 20 seconds. 52 | const liveBuffer = options.liveBuffer || 20000; 53 | const requestOptions = options.requestOptions; 54 | const Parser = supportedParsers[options.parser || (/\.mpd$/.test(playlistURL) ? 'dash-mpd' : 'm3u8')]; 55 | if (!Parser) { 56 | throw TypeError(`parser '${options.parser}' not supported`); 57 | } 58 | let begin = 0; 59 | if (typeof options.begin !== 'undefined') { 60 | begin = typeof options.begin === 'string' ? 61 | humanStr(options.begin) : 62 | Math.max(options.begin - liveBuffer, 0); 63 | } 64 | 65 | const forwardEvents = (req: miniget.Stream) => { 66 | for (let event of ['abort', 'request', 'response', 'redirect', 'retry', 'reconnect']) { 67 | req.on(event, stream.emit.bind(stream, event)); 68 | } 69 | }; 70 | 71 | let currSegment: miniget.Stream | null; 72 | const streamQueue = new Queue((req: miniget.Stream, callback): void => { 73 | currSegment = req; 74 | // Count the size manually, since the `content-length` header is not 75 | // always there. 76 | let size = 0; 77 | req.on('data', (chunk: Buffer) => size += chunk.length); 78 | req.pipe(stream, { end: false }); 79 | req.on('end', () => callback(null, size)); 80 | }, { concurrency: 1 }); 81 | 82 | let segmentNumber = 0; 83 | let downloaded = 0; 84 | const requestQueue = new Queue((segment: Item, callback: Callback): void => { 85 | let reqOptions = Object.assign({}, requestOptions); 86 | if (segment.range) { 87 | reqOptions.headers = Object.assign({}, reqOptions.headers, { 88 | Range: `bytes=${segment.range.start}-${segment.range.end}`, 89 | }); 90 | } 91 | let req = miniget(new URL(segment.url, playlistURL).toString(), reqOptions); 92 | req.on('error', callback); 93 | forwardEvents(req); 94 | streamQueue.push(req, (_, size) => { 95 | downloaded += +size; 96 | stream.emit('progress', { 97 | num: ++segmentNumber, 98 | size: size, 99 | duration: segment.duration, 100 | url: segment.url, 101 | }, requestQueue.total, downloaded); 102 | callback(null); 103 | }); 104 | }, { concurrency: chunkReadahead }); 105 | 106 | const onError = (err: Error): void => { 107 | stream.emit('error', err); 108 | // Stop on any error. 109 | stream.end(); 110 | }; 111 | 112 | // When to look for items again. 113 | let refreshThreshold: number; 114 | let minRefreshTime: number; 115 | let refreshTimeout: NodeJS.Timer; 116 | let fetchingPlaylist = true; 117 | let ended = false; 118 | let isStatic = false; 119 | let lastRefresh: number; 120 | 121 | const onQueuedEnd = (err: Error | null): void => { 122 | currSegment = null; 123 | if (err) { 124 | onError(err); 125 | } else if (!fetchingPlaylist && !ended && !isStatic && 126 | requestQueue.tasks.length + requestQueue.active <= refreshThreshold) { 127 | let ms = Math.max(0, minRefreshTime - (Date.now() - lastRefresh)); 128 | fetchingPlaylist = true; 129 | refreshTimeout = setTimeout(refreshPlaylist, ms); 130 | } else if ((ended || isStatic) && 131 | !requestQueue.tasks.length && !requestQueue.active) { 132 | stream.end(); 133 | } 134 | }; 135 | 136 | let currPlaylist: miniget.Stream | null; 137 | let lastSeq: number; 138 | let starttime = 0; 139 | 140 | const refreshPlaylist = (): void => { 141 | lastRefresh = Date.now(); 142 | currPlaylist = miniget(playlistURL, requestOptions); 143 | currPlaylist.on('error', onError); 144 | forwardEvents(currPlaylist); 145 | const parser = currPlaylist.pipe(new Parser(options.id)); 146 | parser.on('starttime', (a: number) => { 147 | if (starttime) { return; } 148 | starttime = a; 149 | if (typeof options.begin === 'string' && begin >= 0) { 150 | begin += starttime; 151 | } 152 | }); 153 | parser.on('endlist', () => { isStatic = true; }); 154 | parser.on('endearly', currPlaylist.unpipe.bind(currPlaylist, parser)); 155 | 156 | let addedItems: any[] = []; 157 | const addItem = (item: TimedItem): void => { 158 | if (!item.init) { 159 | if (item.seq <= lastSeq) { return; } 160 | lastSeq = item.seq; 161 | } 162 | begin = item.time; 163 | requestQueue.push(item, onQueuedEnd); 164 | addedItems.push(item); 165 | }; 166 | 167 | let tailedItems: TimedItem[] = [], tailedItemsDuration = 0; 168 | parser.on('item', (item: Item) => { 169 | let timedItem = { time: starttime, ...item }; 170 | if (begin <= timedItem.time) { 171 | addItem(timedItem); 172 | } else { 173 | tailedItems.push(timedItem); 174 | tailedItemsDuration += timedItem.duration; 175 | // Only keep the last `liveBuffer` of items. 176 | while (tailedItems.length > 1 && 177 | tailedItemsDuration - tailedItems[0].duration > liveBuffer) { 178 | const lastItem = tailedItems.shift() as TimedItem; 179 | tailedItemsDuration -= lastItem.duration; 180 | } 181 | } 182 | starttime += timedItem.duration; 183 | }); 184 | 185 | parser.on('end', () => { 186 | currPlaylist = null; 187 | // If we are too ahead of the stream, make sure to get the 188 | // latest available items with a small buffer. 189 | if (!addedItems.length && tailedItems.length) { 190 | tailedItems.forEach(item => { addItem(item); }); 191 | } 192 | 193 | // Refresh the playlist when remaining segments get low. 194 | refreshThreshold = Math.max(1, Math.ceil(addedItems.length * 0.01)); 195 | 196 | // Throttle refreshing the playlist by looking at the duration 197 | // of live items added on this refresh. 198 | minRefreshTime = 199 | addedItems.reduce((total, item) => item.duration + total, 0); 200 | 201 | fetchingPlaylist = false; 202 | onQueuedEnd(null); 203 | }); 204 | }; 205 | refreshPlaylist(); 206 | 207 | stream.end = () => { 208 | ended = true; 209 | streamQueue.die(); 210 | requestQueue.die(); 211 | clearTimeout(refreshTimeout); 212 | currPlaylist?.destroy(); 213 | currSegment?.destroy(); 214 | PassThrough.prototype.end.call(stream, null); 215 | return stream; 216 | }; 217 | 218 | return stream; 219 | }) as m3u8stream.m3u8streamFunc; 220 | m3u8stream.parseTimestamp = humanStr; 221 | 222 | export = m3u8stream; 223 | -------------------------------------------------------------------------------- /src/m3u8-parser.ts: -------------------------------------------------------------------------------- 1 | import { Writable } from 'stream'; 2 | import { Parser } from './parser'; 3 | 4 | 5 | /** 6 | * A very simple m3u8 playlist file parser that detects tags and segments. 7 | */ 8 | export default class m3u8Parser extends Writable implements Parser { 9 | private _lastLine: string; 10 | private _seq: number; 11 | private _nextItemDuration: number | null; 12 | private _nextItemRange: { start: number; end: number } | null; 13 | private _lastItemRangeEnd: number; 14 | 15 | constructor() { 16 | super(); 17 | this._lastLine = ''; 18 | this._seq = 0; 19 | this._nextItemDuration = null; 20 | this._nextItemRange = null; 21 | this._lastItemRangeEnd = 0; 22 | this.on('finish', () => { 23 | this._parseLine(this._lastLine); 24 | this.emit('end'); 25 | }); 26 | } 27 | 28 | private _parseAttrList(value: string) { 29 | let attrs: { [key: string]: string } = {}; 30 | let regex = /([A-Z0-9-]+)=(?:"([^"]*?)"|([^,]*?))/g; 31 | let match; 32 | while ((match = regex.exec(value)) !== null) { 33 | attrs[match[1]] = match[2] || match[3]; 34 | } 35 | return attrs; 36 | } 37 | 38 | private _parseRange(value: string) { 39 | if (!value) return null; 40 | let svalue = value.split('@'); 41 | let start = svalue[1] ? parseInt(svalue[1]) : this._lastItemRangeEnd + 1; 42 | let end = start + parseInt(svalue[0]) - 1; 43 | let range = { start, end }; 44 | this._lastItemRangeEnd = range.end; 45 | return range; 46 | } 47 | 48 | _parseLine(line: string): void { 49 | let match = line.match(/^#(EXT[A-Z0-9-]+)(?::(.*))?/); 50 | if (match) { 51 | // This is a tag. 52 | const tag = match[1]; 53 | const value = match[2] || ''; 54 | switch (tag) { 55 | case 'EXT-X-PROGRAM-DATE-TIME': 56 | this.emit('starttime', new Date(value).getTime()); 57 | break; 58 | case 'EXT-X-MEDIA-SEQUENCE': 59 | this._seq = parseInt(value); 60 | break; 61 | case 'EXT-X-MAP': { 62 | let attrs = this._parseAttrList(value); 63 | if (!attrs.URI) { 64 | this.destroy( 65 | new Error('`EXT-X-MAP` found without required attribute `URI`')); 66 | return; 67 | } 68 | this.emit('item', { 69 | url: attrs.URI, 70 | seq: this._seq, 71 | init: true, 72 | duration: 0, 73 | range: this._parseRange(attrs.BYTERANGE), 74 | }); 75 | break; 76 | } 77 | case 'EXT-X-BYTERANGE': { 78 | this._nextItemRange = this._parseRange(value); 79 | break; 80 | } 81 | case 'EXTINF': 82 | this._nextItemDuration = 83 | Math.round(parseFloat(value.split(',')[0]) * 1000); 84 | break; 85 | case 'EXT-X-ENDLIST': 86 | this.emit('endlist'); 87 | break; 88 | } 89 | } else if (!/^#/.test(line) && line.trim()) { 90 | // This is a segment 91 | this.emit('item', { 92 | url: line.trim(), 93 | seq: this._seq++, 94 | duration: this._nextItemDuration, 95 | range: this._nextItemRange, 96 | }); 97 | this._nextItemRange = null; 98 | } 99 | } 100 | 101 | _write(chunk: Buffer, encoding: string, callback: () => void): void { 102 | let lines: string[] = chunk.toString('utf8').split('\n'); 103 | if (this._lastLine) { lines[0] = this._lastLine + lines[0]; } 104 | lines.forEach((line: string, i: number) => { 105 | if (this.destroyed) return; 106 | if (i < lines.length - 1) { 107 | this._parseLine(line); 108 | } else { 109 | // Save the last line in case it has been broken up. 110 | this._lastLine = line; 111 | } 112 | }); 113 | callback(); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/parse-time.ts: -------------------------------------------------------------------------------- 1 | const numberFormat = /^\d+$/; 2 | const timeFormat = /^(?:(?:(\d+):)?(\d{1,2}):)?(\d{1,2})(?:\.(\d{3}))?$/; 3 | const timeUnits: { [key: string]: number } = { 4 | ms: 1, 5 | s: 1000, 6 | m: 60000, 7 | h: 3600000, 8 | }; 9 | 10 | /** 11 | * Converts human friendly time to milliseconds. Supports the format 12 | * 00:00:00.000 for hours, minutes, seconds, and milliseconds respectively. 13 | * And 0ms, 0s, 0m, 0h, and together 1m1s. 14 | * 15 | * @param {number|string} time 16 | * @returns {number} 17 | */ 18 | export const humanStr = (time: number | string): number => { 19 | if (typeof time === 'number') { return time; } 20 | if (numberFormat.test(time)) { return +time; } 21 | const firstFormat = timeFormat.exec(time); 22 | if (firstFormat) { 23 | return (+(firstFormat[1] || 0) * timeUnits.h) + 24 | (+(firstFormat[2] || 0) * timeUnits.m) + 25 | (+firstFormat[3] * timeUnits.s) + 26 | +(firstFormat[4] || 0); 27 | } else { 28 | let total = 0; 29 | const r = /(-?\d+)(ms|s|m|h)/g; 30 | let rs; 31 | while ((rs = r.exec(time)) !== null) { 32 | total += +rs[1] * timeUnits[rs[2]]; 33 | } 34 | return total; 35 | } 36 | }; 37 | 38 | /** 39 | * Parses a duration string in the form of "123.456S", returns milliseconds. 40 | * 41 | * @param {string} time 42 | * @returns {number} 43 | */ 44 | export const durationStr = (time: string): number => { 45 | let total = 0; 46 | const r = /(\d+(?:\.\d+)?)(S|M|H)/g; 47 | let rs: RegExpExecArray | null; 48 | while ((rs = r.exec(time)) !== null) { 49 | total += +rs[1] * timeUnits[rs[2].toLowerCase()]; 50 | } 51 | return total; 52 | }; 53 | -------------------------------------------------------------------------------- /src/parser.ts: -------------------------------------------------------------------------------- 1 | import { Writable } from 'stream'; 2 | 3 | export interface Item { 4 | url: string; 5 | seq: number; 6 | duration: number; 7 | time?: number; 8 | range?: { start: number; end: number }; 9 | init?: boolean; 10 | } 11 | 12 | export interface Parser extends Writable { 13 | on(event: 'item', listener: (item: Item) => boolean): this; 14 | on(event: string | symbol, listener: (...args: any[]) => any): this; 15 | emit(event: 'item', item: Item): boolean; 16 | emit(event: string, ...args: any[]): boolean; 17 | } 18 | -------------------------------------------------------------------------------- /src/queue.ts: -------------------------------------------------------------------------------- 1 | export type Callback = (err: Error | null, result?: any) => void; 2 | interface Task { 3 | item: T; 4 | callback?: Callback; 5 | } 6 | type Worker = (item: T, cb: Callback) => void; 7 | 8 | export class Queue { 9 | private _worker: Worker; 10 | private _concurrency: number; 11 | tasks: Task[]; 12 | total: number; 13 | active: number; 14 | 15 | /** 16 | * A really simple queue with concurrency. 17 | * 18 | * @param {Function} worker 19 | * @param {Object} options 20 | * @param {!number} options.concurrency 21 | */ 22 | constructor(worker: Worker, options: { concurrency?: number } = {}) { 23 | this._worker = worker; 24 | this._concurrency = options.concurrency || 1; 25 | this.tasks = []; 26 | this.total = 0; 27 | this.active = 0; 28 | } 29 | 30 | 31 | /** 32 | * Push a task to the queue. 33 | * 34 | * @param {T} item 35 | * @param {!Function} callback 36 | */ 37 | push(item: T, callback?: Callback): void { 38 | this.tasks.push({ item, callback }); 39 | this.total++; 40 | this._next(); 41 | } 42 | 43 | 44 | /** 45 | * Process next job in queue. 46 | */ 47 | _next(): void { 48 | if (this.active >= this._concurrency || !this.tasks.length) { return; } 49 | const { item, callback } = this.tasks.shift() as Task; 50 | let callbackCalled = false; 51 | this.active++; 52 | this._worker(item, (err, result) => { 53 | if (callbackCalled) { return; } 54 | this.active--; 55 | callbackCalled = true; 56 | callback?.(err, result); 57 | this._next(); 58 | }); 59 | } 60 | 61 | 62 | /** 63 | * Stops processing queued jobs. 64 | */ 65 | die(): void { 66 | this.tasks = []; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /test/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | mocha: true 3 | rules: 4 | max-nested-callbacks: off 5 | '@typescript-eslint/ban-ts-comment': off 6 | -------------------------------------------------------------------------------- /test/.gitattributes: -------------------------------------------------------------------------------- 1 | # mp4 files are binary and should not have line-endings changed 2 | # no matter the individual developer settings 3 | *.mp4 binary -------------------------------------------------------------------------------- /test/dash-mpd-parser-test.ts: -------------------------------------------------------------------------------- 1 | import DashMPDParser from '../dist/dash-mpd-parser'; 2 | import { Item } from '../dist/parser'; 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | import assert from 'assert'; 6 | 7 | 8 | describe('dash MPD parser', () => { 9 | describe('Playlist with one representation', () => { 10 | it('Emits all segments', done => { 11 | let filepath = path.resolve(__dirname, 'playlists/simple.mpd'); 12 | let items: Item[] = []; 13 | let endlist = false; 14 | const parser = new DashMPDParser(); 15 | let starttime: number; 16 | parser.on('starttime', a => starttime = a); 17 | parser.on('item', item => { items.push(item); }); 18 | parser.on('endlist', () => { endlist = true; }); 19 | parser.on('error', done); 20 | let rs = fs.createReadStream(filepath); 21 | rs.pipe(parser); 22 | rs.on('end', () => { 23 | assert.equal(new Date(starttime).toLocaleTimeString().split(' ')[0], 24 | '12:24:21'); 25 | assert.ok(!endlist); 26 | assert.deepEqual(items, [ 27 | { url: 'https://videohost.com/139/0001.ts', 28 | duration: 2000, seq: 1 }, 29 | { url: 'https://videohost.com/139/0002.ts', 30 | duration: 2000, seq: 2 }, 31 | { url: 'https://videohost.com/139/0003.ts', 32 | duration: 2000, seq: 3 }, 33 | { url: 'https://videohost.com/139/0004.ts', 34 | duration: 2000, seq: 4 }, 35 | { url: 'https://videohost.com/139/0005.ts', 36 | duration: 2000, seq: 5 }, 37 | { url: 'https://videohost.com/139/0006.ts', 38 | duration: 2000, seq: 6 }, 39 | { url: 'https://videohost.com/139/0007.ts', 40 | duration: 2000, seq: 7 }, 41 | { url: 'https://videohost.com/139/0008.ts', 42 | duration: 2000, seq: 8 }, 43 | { url: 'https://videohost.com/139/0009.ts', 44 | duration: 2000, seq: 9 }, 45 | { url: 'https://videohost.com/139/0010.ts', 46 | duration: 2000, seq: 10 }, 47 | ]); 48 | done(); 49 | }); 50 | }); 51 | }); 52 | 53 | describe('Playlist with multiple representations', () => { 54 | it('Emits all segments', done => { 55 | let filepath = path.resolve(__dirname, 56 | 'playlists/multi-representation.mpd'); 57 | let items: Item[] = []; 58 | let endlist = false; 59 | const parser = new DashMPDParser('140'); 60 | parser.on('item', item => { items.push(item); }); 61 | parser.on('endlist', () => { endlist = true; }); 62 | parser.on('error', done); 63 | let rs = fs.createReadStream(filepath); 64 | rs.pipe(parser); 65 | rs.on('end', () => { 66 | assert.ok(endlist); 67 | assert.deepEqual(items, [ 68 | { url: 'https://videohost.com/140/0000.ts', 69 | duration: 0, seq: 1, init: true }, 70 | { url: 'https://videohost.com/140/0001.ts', 71 | duration: 2000, seq: 1 }, 72 | { url: 'https://videohost.com/140/0002.ts', 73 | duration: 2000, seq: 2 }, 74 | { url: 'https://videohost.com/140/0003.ts', 75 | duration: 2000, seq: 3 }, 76 | { url: 'https://videohost.com/140/0004.ts', 77 | duration: 2000, seq: 4 }, 78 | { url: 'https://videohost.com/140/0005.ts', 79 | duration: 2000, seq: 5 }, 80 | { url: 'https://videohost.com/140/0006.ts', 81 | duration: 2000, seq: 6 }, 82 | { url: 'https://videohost.com/140/0007.ts', 83 | duration: 2000, seq: 7 }, 84 | { url: 'https://videohost.com/140/0008.ts', 85 | duration: 2000, seq: 8 }, 86 | { url: 'https://videohost.com/140/0009.ts', 87 | duration: 2000, seq: 9 }, 88 | { url: 'https://videohost.com/140/0010.ts', 89 | duration: 2000, seq: 10 }, 90 | ]); 91 | done(); 92 | }); 93 | }); 94 | 95 | describe('With a representation with initialization segment', () => { 96 | it('Emits all segments', done => { 97 | let filepath = path.resolve(__dirname, 98 | 'playlists/multi-representation.mpd'); 99 | let items: Item[] = []; 100 | let endlist = false; 101 | const parser = new DashMPDParser('133'); 102 | parser.on('item', item => { items.push(item); }); 103 | parser.on('endlist', () => { endlist = true; }); 104 | parser.on('error', done); 105 | let rs = fs.createReadStream(filepath); 106 | rs.pipe(parser); 107 | rs.on('end', () => { 108 | assert.ok(endlist); 109 | assert.deepEqual(items, [ 110 | { url: 'https://videohost.com/133/0001.ts', 111 | duration: 2000, seq: 1 }, 112 | { url: 'https://videohost.com/133/0002.ts', 113 | duration: 2000, seq: 2 }, 114 | { url: 'https://videohost.com/133/0003.ts', 115 | duration: 2000, seq: 3 }, 116 | { url: 'https://videohost.com/133/0004.ts', 117 | duration: 2000, seq: 4 }, 118 | { url: 'https://videohost.com/133/0005.ts', 119 | duration: 2000, seq: 5 }, 120 | { url: 'https://videohost.com/133/0006.ts', 121 | duration: 2000, seq: 6 }, 122 | { url: 'https://videohost.com/133/0007.ts', 123 | duration: 2000, seq: 7 }, 124 | { url: 'https://videohost.com/133/0008.ts', 125 | duration: 2000, seq: 8 }, 126 | { url: 'https://videohost.com/133/0009.ts', 127 | duration: 2000, seq: 9 }, 128 | { url: 'https://videohost.com/133/0010.ts', 129 | duration: 2000, seq: 10 }, 130 | ]); 131 | done(); 132 | }); 133 | }); 134 | }); 135 | 136 | describe('With a target representation that isn\'t found', () => { 137 | it('Emits error', done => { 138 | let filepath = path.resolve(__dirname, 139 | 'playlists/multi-representation.mpd'); 140 | let items = []; 141 | let endlist = false; 142 | let id = 'willnotfindthis'; 143 | const parser = new DashMPDParser(id); 144 | parser.on('item', item => { items.push(item); }); 145 | parser.on('endlist', () => { endlist = true; }); 146 | parser.on('error', err => { 147 | assert.ok(endlist); 148 | assert.equal(items.length, 0); 149 | assert.equal(err.message, `Representation '${id}' not found`); 150 | done(); 151 | }); 152 | let rs = fs.createReadStream(filepath); 153 | rs.pipe(parser); 154 | }); 155 | }); 156 | }); 157 | 158 | describe('Static playlist', () => { 159 | it('Emits all segments', done => { 160 | let filepath = path.resolve(__dirname, 'playlists/example.mpd'); 161 | let items: Item[] = []; 162 | let endlist = false; 163 | const parser = new DashMPDParser(); 164 | parser.on('item', item => { items.push(item); }); 165 | parser.on('endlist', () => { endlist = true; }); 166 | parser.on('error', done); 167 | let rs = fs.createReadStream(filepath); 168 | rs.pipe(parser); 169 | rs.on('end', () => { 170 | assert.ok(endlist); 171 | assert.deepEqual(items, [ 172 | { url: 'main/video/720p/segment-1.ts', 173 | duration: 60000, seq: 0 }, 174 | { url: 'main/video/720p/segment-2.ts', 175 | duration: 60000, seq: 1 }, 176 | { url: 'main/video/720p/segment-3.ts', 177 | duration: 60000, seq: 2 }, 178 | { url: 'main/video/720p/segment-4.ts', 179 | duration: 60000, seq: 3 }, 180 | { url: 'main/video/720p/segment-5.ts', 181 | duration: 60000, seq: 4 }, 182 | { url: 'main/video/720p/segment-6.ts', 183 | duration: 60000, seq: 5 }, 184 | { url: 'main/video/720p/segment-7.ts', 185 | duration: 60000, seq: 6 }, 186 | { url: 'main/video/720p/segment-8.ts', 187 | duration: 60000, seq: 7 }, 188 | { url: 'main/video/720p/segment-9.ts', 189 | duration: 60000, seq: 8 }, 190 | { url: 'main/video/720p/segment-10.ts', 191 | duration: 60000, seq: 9 }, 192 | ]); 193 | done(); 194 | }); 195 | }); 196 | }); 197 | 198 | describe('Playlist with ', () => { 199 | it('Segments are generated and emitted', done => { 200 | let filepath = path.resolve(__dirname, 'playlists/segment-template.mpd'); 201 | let items: Item[] = []; 202 | let endlist = false; 203 | let timescale = 22050; 204 | const parser = new DashMPDParser(); 205 | parser.on('item', item => { items.push(item); }); 206 | parser.on('endlist', () => { endlist = true; }); 207 | parser.on('error', done); 208 | let rs = fs.createReadStream(filepath); 209 | rs.pipe(parser); 210 | rs.on('end', () => { 211 | assert.ok(endlist); 212 | assert.deepEqual(items, [ 213 | { url: 'media/audio/und/init.mp4', 214 | duration: 0, seq: 1, init: true }, 215 | { url: 'media/audio/und/seg-1.m4f', 216 | duration: 44032 / timescale * 1000, seq: 1 }, 217 | { url: 'media/audio/und/seg-2.m4f', 218 | duration: 44032 / timescale * 1000, seq: 2 }, 219 | { url: 'media/audio/und/seg-3.m4f', 220 | duration: 44032 / timescale * 1000, seq: 3 }, 221 | { url: 'media/audio/und/seg-4.m4f', 222 | duration: 45056 / timescale * 1000, seq: 4 }, 223 | { url: 'media/audio/und/seg-5.m4f', 224 | duration: 44032 / timescale * 1000, seq: 5 }, 225 | { url: 'media/audio/und/seg-6.m4f', 226 | duration: 44032 / timescale * 1000, seq: 6 }, 227 | { url: 'media/audio/und/seg-7.m4f', 228 | duration: 44032 / timescale * 1000, seq: 7 }, 229 | { url: 'media/audio/und/seg-8.m4f', 230 | duration: 44032 / timescale * 1000, seq: 8 }, 231 | { url: 'media/audio/und/seg-9.m4f', 232 | duration: 44032 / timescale * 1000, seq: 9 }, 233 | { url: 'media/audio/und/seg-10.m4f', 234 | duration: 45056 / timescale * 1000, seq: 10 }, 235 | { url: 'media/audio/und/seg-11.m4f', 236 | duration: 44032 / timescale * 1000, seq: 11 }, 237 | { url: 'media/audio/und/seg-12.m4f', 238 | duration: 44032 / timescale * 1000, seq: 12 }, 239 | { url: 'media/audio/und/seg-13.m4f', 240 | duration: 44032 / timescale * 1000, seq: 13 }, 241 | { url: 'media/audio/und/seg-14.m4f', 242 | duration: 44032 / timescale * 1000, seq: 14 }, 243 | { url: 'media/audio/und/seg-15.m4f', 244 | duration: 44032 / timescale * 1000, seq: 15 }, 245 | { url: 'media/audio/und/seg-16.m4f', 246 | duration: 3904 / timescale * 1000, seq: 16 }, 247 | ]); 248 | done(); 249 | }); 250 | }); 251 | 252 | describe('Without initialization segment', () => { 253 | it('Segments are generated and emitted', done => { 254 | let filepath = path.resolve(__dirname, 'playlists/segment-template-2.mpd'); 255 | let items: Item[] = []; 256 | let endlist = false; 257 | let timescale = 1000; 258 | const parser = new DashMPDParser(); 259 | parser.on('item', item => { items.push(item); }); 260 | parser.on('endlist', () => { endlist = true; }); 261 | parser.on('error', done); 262 | let rs = fs.createReadStream(filepath); 263 | rs.pipe(parser); 264 | rs.on('end', () => { 265 | assert.ok(endlist); 266 | assert.deepEqual(items, [ 267 | { url: 'audio/und/seg-0.m4f', 268 | duration: 2000 / timescale * 1000, seq: 0 }, 269 | { url: 'audio/und/seg-1.m4f', 270 | duration: 2000 / timescale * 1000, seq: 1 }, 271 | { url: 'audio/und/seg-2.m4f', 272 | duration: 2000 / timescale * 1000, seq: 2 }, 273 | { url: 'audio/und/seg-3.m4f', 274 | duration: 2000 / timescale * 1000, seq: 3 }, 275 | ]); 276 | done(); 277 | }); 278 | }); 279 | }); 280 | 281 | describe('Contains inside ', () => { 282 | it('Segments are emitted', done => { 283 | let filepath = path.resolve(__dirname, 'playlists/facebook.mpd'); 284 | const parser = new DashMPDParser(); 285 | let items: Item[] = []; 286 | parser.on('item', item => items.push(item)); 287 | parser.on('error', done); 288 | fs.createReadStream(filepath).pipe(parser).on('end', () => { 289 | assert.deepEqual(items, [ 290 | { url: '../live-md-v/122643152223588_0-init.m4v', 291 | duration: 0, seq: 0, init: true }, 292 | { url: '../live-md-v/122643152223588_0-36874.m4v', 293 | duration: 2000, seq: 0 }, 294 | { url: '../live-md-v/122643152223588_0-38874.m4v', 295 | duration: 2000, seq: 1 }, 296 | { url: '../live-md-v/122643152223588_0-40874.m4v', 297 | duration: 2000, seq: 2 }, 298 | { url: '../live-md-v/122643152223588_0-42874.m4v', 299 | duration: 2000, seq: 3 }, 300 | { url: '../live-md-v/122643152223588_0-44874.m4v', 301 | duration: 2000, seq: 4 }, 302 | { url: '../live-md-v/122643152223588_0-46874.m4v', 303 | duration: 2000, seq: 5 }, 304 | { url: '../live-md-v/122643152223588_0-48874.m4v', 305 | duration: 2000, seq: 6 }, 306 | { url: '../live-md-v/122643152223588_0-50874.m4v', 307 | duration: 2000, seq: 7 }, 308 | { url: '../live-md-v/122643152223588_0-52874.m4v', 309 | duration: 2000, seq: 8 }, 310 | { url: '../live-md-v/122643152223588_0-54874.m4v', 311 | duration: 2000, seq: 9 }, 312 | ]); 313 | done(); 314 | }); 315 | }); 316 | }); 317 | }); 318 | }); 319 | -------------------------------------------------------------------------------- /test/m3u8-parser-test.ts: -------------------------------------------------------------------------------- 1 | import m3u8Parser from '../dist/m3u8-parser'; 2 | import { Item } from '../dist/parser'; 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | import assert from 'assert'; 6 | 7 | 8 | describe('m3u8 parser', () => { 9 | describe('Parse segments from a simple playlist', () => { 10 | it('Emits all segments', done => { 11 | let filepath = path.resolve(__dirname, 'playlists/simple.m3u8'); 12 | let items: Item[] = []; 13 | let endlist = false; 14 | const parser = new m3u8Parser(); 15 | parser.on('item', item => { items.push(item); }); 16 | parser.on('endlist', () => { endlist = true; }); 17 | parser.on('error', done); 18 | let rs = fs.createReadStream(filepath, { highWaterMark: 16 }); 19 | rs.pipe(parser); 20 | rs.on('end', () => { 21 | assert.ok(endlist); 22 | assert.deepEqual(items, [ 23 | { url: 'http://media.example.com/first.ts', 24 | seq: 0, duration: 9009, range: null }, 25 | { url: 'http://media.example.com/second.ts', 26 | seq: 1, duration: 9009, range: null }, 27 | { url: 'http://media.example.com/third.ts', 28 | seq: 2, duration: 3003, range: null }, 29 | ]); 30 | done(); 31 | }); 32 | }); 33 | }); 34 | 35 | describe('Parse segments from a live playlist', () => { 36 | it('Emits all segments', done => { 37 | let filepath = path.resolve(__dirname, 'playlists/live-1.1.m3u8'); 38 | let items: Item[] = []; 39 | let endlist = false; 40 | const parser = new m3u8Parser(); 41 | parser.on('item', item => { items.push(item); }); 42 | parser.on('endlist', () => { endlist = true; }); 43 | parser.on('error', done); 44 | let rs = fs.createReadStream(filepath); 45 | rs.pipe(parser); 46 | rs.on('end', () => { 47 | assert.ok(!endlist); 48 | assert.deepEqual(items, [ 49 | { url: 'https://priv.example.com/fileSequence2681.ts', 50 | seq: 2681, duration: 7975, range: null }, 51 | { url: 'https://priv.example.com/fileSequence2682.ts', 52 | seq: 2682, duration: 7941, range: null }, 53 | { url: 'https://priv.example.com/fileSequence2683.ts', 54 | seq: 2683, duration: 7975, range: null }, 55 | ]); 56 | done(); 57 | }); 58 | }); 59 | }); 60 | 61 | describe('Plalist contains `EXT-X-MAP`', () => { 62 | it('Emits initialization segment', done => { 63 | let filepath = path.resolve(__dirname, 'playlists/x-map-1.m3u8'); 64 | let items: Item[] = []; 65 | let endlist = false; 66 | const parser = new m3u8Parser(); 67 | parser.on('item', item => { items.push(item); }); 68 | parser.on('endlist', () => { endlist = true; }); 69 | parser.on('error', done); 70 | let rs = fs.createReadStream(filepath); 71 | rs.pipe(parser); 72 | rs.on('end', () => { 73 | assert.ok(endlist); 74 | assert.deepEqual(items, [ 75 | { url: 'init.mp4', init: true, 76 | seq: 1, duration: 0, range: null }, 77 | { url: 'main1.mp4', 78 | seq: 1, duration: 4969, range: null }, 79 | { url: 'main2.mp4', 80 | seq: 2, duration: 4969, range: null }, 81 | { url: 'main3.mp4', 82 | seq: 3, duration: 4969, range: null }, 83 | { url: 'main4.mp4', 84 | seq: 4, duration: 4969, range: null }, 85 | ]); 86 | done(); 87 | }); 88 | }); 89 | 90 | describe('Without `URI`', () => { 91 | it('Emits error', done => { 92 | let filepath = path.resolve(__dirname, 'playlists/x-map-2.m3u8'); 93 | let items: Item[] = []; 94 | let endlist = false; 95 | const parser = new m3u8Parser(); 96 | parser.on('item', item => { items.push(item); }); 97 | parser.on('endlist', () => { endlist = true; }); 98 | parser.on('error', err => { 99 | assert.ok(!endlist); 100 | assert.equal(items.length, 0); 101 | assert.ok(err); 102 | done(); 103 | }); 104 | let rs = fs.createReadStream(filepath); 105 | rs.pipe(parser); 106 | rs.on('end', () => { 107 | done(new Error('should not emit end')); 108 | }); 109 | }); 110 | }); 111 | 112 | describe('Twice in one playlist', () => { 113 | it('Emits initialization segment', done => { 114 | let filepath = path.resolve(__dirname, 'playlists/x-map-3.m3u8'); 115 | let items: Item[] = []; 116 | let endlist = false; 117 | const parser = new m3u8Parser(); 118 | parser.on('item', item => { items.push(item); }); 119 | parser.on('endlist', () => { endlist = true; }); 120 | parser.on('error', done); 121 | let rs = fs.createReadStream(filepath); 122 | rs.pipe(parser); 123 | rs.on('end', () => { 124 | assert.ok(endlist); 125 | assert.deepEqual(items, [ 126 | { url: 'main.mp4', init: true, 127 | seq: 1, duration: 0, range: { start: 0, end: 49 } }, 128 | { url: 'main.mp4', 129 | seq: 1, duration: 4969, range: { start: 50, end: 124 } }, 130 | { url: 'main.mp4', 131 | seq: 2, duration: 4969, range: { start: 125, end: 194 } }, 132 | { url: 'main.mp4', init: true, 133 | seq: 3, duration: 0, range: { start: 195, end: 244 } }, 134 | { url: 'main.mp4', 135 | seq: 3, duration: 4969, range: { start: 245, end: 314 } }, 136 | { url: 'main.mp4', 137 | seq: 4, duration: 4969, range: { start: 315, end: 394 } }, 138 | ]); 139 | done(); 140 | }); 141 | }); 142 | }); 143 | }); 144 | 145 | describe('Playlist contains `EXT-X-BYTERANGE`', () => { 146 | it('Emits items with range', done => { 147 | let filepath = path.resolve(__dirname, 'playlists/x-byterange-1.m3u8'); 148 | let items: Item[] = []; 149 | let endlist = false; 150 | const parser = new m3u8Parser(); 151 | parser.on('item', item => { items.push(item); }); 152 | parser.on('endlist', () => { endlist = true; }); 153 | parser.on('error', done); 154 | let rs = fs.createReadStream(filepath); 155 | rs.pipe(parser); 156 | rs.on('end', () => { 157 | assert.ok(endlist); 158 | assert.deepEqual(items, [ 159 | { url: 'main.mp4', init: true, 160 | seq: 1, duration: 0, range: { start: 0, end: 49 } }, 161 | { url: 'main.mp4', 162 | seq: 1, duration: 4969, range: { start: 50, end: 124 } }, 163 | { url: 'main.mp4', 164 | seq: 2, duration: 4969, range: { start: 125, end: 194 } }, 165 | { url: 'main.mp4', 166 | seq: 3, duration: 4969, range: { start: 195, end: 264 } }, 167 | { url: 'main.mp4', 168 | seq: 4, duration: 4969, range: { start: 265, end: 344 } }, 169 | ]); 170 | done(); 171 | }); 172 | }); 173 | }); 174 | }); 175 | -------------------------------------------------------------------------------- /test/main-test.ts: -------------------------------------------------------------------------------- 1 | import m3u8stream from '../dist/index'; 2 | import path from 'path'; 3 | import fs from 'fs'; 4 | import assert from 'assert'; 5 | import { PassThrough } from 'stream'; 6 | import nock from 'nock'; 7 | import { spy } from 'sinon'; 8 | import { humanStr } from '../dist/parse-time'; 9 | 10 | 11 | nock.disableNetConnect(); 12 | const concat = (stream: PassThrough, callback: (err: Error | null, body: string) => void) => { 13 | let body = ''; 14 | stream.setEncoding('utf8'); 15 | stream.on('data', (chunk: string) => { body += chunk; }); 16 | stream.on('error', callback); 17 | stream.on('end', () => { callback(null, body); }); 18 | }; 19 | 20 | describe('m3u8stream', () => { 21 | let setTimeout = global.setTimeout; 22 | before(() => { global.setTimeout = process.nextTick as typeof global.setTimeout; }); 23 | after(() => { global.setTimeout = setTimeout; }); 24 | 25 | describe('Simple media playlist', () => { 26 | it('Concatenates segments into stream', done => { 27 | let scope = nock('http://media.example.com') 28 | .get('/playlist.m3u8') 29 | .replyWithFile(200, path.resolve(__dirname, 'playlists/simple.m3u8')) 30 | .get('/first.ts') 31 | .reply(200, 'one') 32 | .get('/second.ts') 33 | .reply(200, 'two') 34 | .get('/third.ts') 35 | .reply(200, 'three'); 36 | let stream = m3u8stream('http://media.example.com/playlist.m3u8'); 37 | concat(stream, (err, body) => { 38 | assert.ifError(err); 39 | scope.done(); 40 | assert.equal(body, 'onetwothree'); 41 | done(); 42 | }); 43 | }); 44 | 45 | it('Concatenates relative segments into stream', done => { 46 | let scope = nock('http://media.example.com') 47 | .get('/playlist.m3u8') 48 | .replyWithFile(200, 49 | path.resolve(__dirname, 'playlists/simple_relative.m3u8')) 50 | .get('/first.ts') 51 | .reply(200, 'one') 52 | .get('/second.ts') 53 | .reply(200, 'two') 54 | .get('/third.ts') 55 | .reply(200, 'three'); 56 | let stream = m3u8stream('http://media.example.com/playlist.m3u8'); 57 | concat(stream, (err, body) => { 58 | assert.ifError(err); 59 | scope.done(); 60 | assert.equal(body, 'onetwothree'); 61 | done(); 62 | }); 63 | }); 64 | 65 | it('Tracks segment download progress', done => { 66 | let scope = nock('http://media.example.com') 67 | .get('/playlist.m3u8') 68 | .replyWithFile(200, path.resolve(__dirname, 'playlists/simple.m3u8')) 69 | .get('/first.ts') 70 | .reply(200, 'one') 71 | .get('/second.ts') 72 | .reply(200, 'two') 73 | .get('/third.ts') 74 | .reply(200, 'three'); 75 | let stream = m3u8stream('http://media.example.com/playlist.m3u8'); 76 | let progress: [m3u8stream.Progress, number, number][] = []; 77 | stream.on('progress', (segment, total, downloaded) => { 78 | progress.push([segment, total, downloaded]); 79 | }); 80 | concat(stream, (err, body) => { 81 | assert.ifError(err); 82 | scope.done(); 83 | assert.equal(body, 'onetwothree'); 84 | assert.deepEqual(progress, [ 85 | [{ 86 | duration: 9009, 87 | num: 1, 88 | size: 3, 89 | url: 'http://media.example.com/first.ts', 90 | }, 3, 3], 91 | [{ 92 | duration: 9009, 93 | num: 2, 94 | size: 3, 95 | url: 'http://media.example.com/second.ts', 96 | }, 3, 6], 97 | [{ 98 | duration: 3003, 99 | num: 3, 100 | size: 5, 101 | url: 'http://media.example.com/third.ts', 102 | }, 3, 11], 103 | ]); 104 | done(); 105 | }); 106 | }); 107 | 108 | it('Forwards events from miniget', done => { 109 | let scope = nock('http://media.example.com') 110 | .get('/playlist.m3u8') 111 | .replyWithFile(200, path.resolve(__dirname, 'playlists/simple.m3u8')) 112 | .get('/first.ts') 113 | .reply(200, '1') 114 | .get('/second.ts') 115 | .reply(200, '2') 116 | .get('/third.ts') 117 | .reply(200, '3'); 118 | let stream = m3u8stream('http://media.example.com/playlist.m3u8'); 119 | let reqSpy = spy(); 120 | let resSpy = spy(); 121 | stream.on('request', reqSpy); 122 | stream.on('response', resSpy); 123 | concat(stream, err => { 124 | assert.ifError(err); 125 | scope.done(); 126 | assert.equal(reqSpy.callCount, 4); 127 | assert.equal(resSpy.callCount, 4); 128 | done(); 129 | }); 130 | }); 131 | 132 | describe('With `begin` set using relative format', () => { 133 | it('Starts stream on segment that matches `begin`', done => { 134 | let scope = nock('https://twitch.tv') 135 | .get('/videos/sc.m3u8') 136 | .replyWithFile(200, path.resolve(__dirname, 137 | 'playlists/twitch-1.1.m3u8')) 138 | .get('/videos/3.ts') 139 | .reply(200, 'the') 140 | .get('/videos/4.ts') 141 | .reply(200, 'big') 142 | .get('/videos/5.ts') 143 | .reply(200, 'brown') 144 | .get('/videos/6.ts') 145 | .reply(200, 'fox') 146 | .get('/videos/7.ts') 147 | .reply(200, 'jumped') 148 | .get('/videos/8.ts') 149 | .reply(200, 'over') 150 | .get('/videos/9.ts') 151 | .reply(200, 'the') 152 | .get('/videos/10.ts') 153 | .reply(200, 'lazy') 154 | .get('/videos/11.ts') 155 | .reply(200, 'dog') 156 | .get('/videos/12.ts') 157 | .reply(200, 'and') 158 | .get('/videos/13.ts') 159 | .reply(200, 'then') 160 | .get('/videos/14.ts') 161 | .reply(200, 'went') 162 | .get('/videos/15.ts') 163 | .reply(200, 'home'); 164 | 165 | let stream = m3u8stream('https://twitch.tv/videos/sc.m3u8', { begin: '30s' }); 166 | concat(stream, (err, body) => { 167 | assert.ifError(err); 168 | scope.done(); 169 | assert.equal(body, [ 170 | 'the', 171 | 'big', 172 | 'brown', 173 | 'fox', 174 | 'jumped', 175 | 'over', 176 | 'the', 177 | 'lazy', 178 | 'dog', 179 | 'and', 180 | 'then', 181 | 'went', 182 | 'home', 183 | ].join('')); 184 | done(); 185 | }); 186 | }); 187 | }); 188 | }); 189 | 190 | describe('Live media playlist', () => { 191 | it('Refresh after nearing end of segment list', done => { 192 | let scope = nock('https://priv.example.com') 193 | .get('/playlist.m3u8') 194 | .replyWithFile(200, path.resolve(__dirname, 195 | 'playlists/live-2.1.m3u8')) 196 | .get('/fileSequence2681.ts') 197 | .reply(200, 'apple') 198 | .get('/fileSequence2682.ts') 199 | .reply(200, 'banana') 200 | .get('/fileSequence2683.ts') 201 | .reply(200, 'cherry') 202 | .get('/fileSequence2684.ts') 203 | .reply(200, 'durango') 204 | .get('/fileSequence2685.ts') 205 | .reply(200, 'eggfruit') 206 | .get('/fileSequence2686.ts') 207 | .reply(200, 'fig') 208 | .get('/fileSequence2687.ts') 209 | .reply(200, 'grape') 210 | .get('/fileSequence2688.ts') 211 | .reply(200, 'hackberry') 212 | .get('/fileSequence2689.ts') 213 | .reply(200, 'imbe') 214 | .get('/fileSequence2690.ts') 215 | .reply(200, 'java') 216 | .get('/playlist.m3u8') 217 | .replyWithFile(200, path.resolve(__dirname, 218 | 'playlists/live-2.2.m3u8')) 219 | .get('/fileSequence2691.ts') 220 | .reply(200, 'kiwi') 221 | .get('/fileSequence2692.ts') 222 | .reply(200, 'lime') 223 | .get('/fileSequence2693.ts') 224 | .reply(200, 'melon') 225 | .get('/fileSequence2694.ts') 226 | .reply(200, 'nut') 227 | .get('/fileSequence2695.ts') 228 | .reply(200, 'orange') 229 | .get('/fileSequence2696.ts') 230 | .reply(200, 'pear') 231 | .get('/fileSequence2697.ts') 232 | .reply(200, 'melon') 233 | .get('/fileSequence2698.ts') 234 | .reply(200, 'quince') 235 | .get('/fileSequence2699.ts') 236 | .reply(200, 'raspberry') 237 | .get('/fileSequence2700.ts') 238 | .reply(200, 'strawberry'); 239 | 240 | let stream = m3u8stream('https://priv.example.com/playlist.m3u8'); 241 | concat(stream, (err, body) => { 242 | assert.ifError(err); 243 | scope.done(); 244 | assert.equal(body, [ 245 | 'apple', 246 | 'banana', 247 | 'cherry', 248 | 'durango', 249 | 'eggfruit', 250 | 'fig', 251 | 'grape', 252 | 'hackberry', 253 | 'imbe', 254 | 'java', 255 | 'kiwi', 256 | 'lime', 257 | 'melon', 258 | 'nut', 259 | 'orange', 260 | 'pear', 261 | 'melon', 262 | 'quince', 263 | 'raspberry', 264 | 'strawberry', 265 | ].join('')); 266 | done(); 267 | }); 268 | }); 269 | 270 | it('Stops on error getting playlist', done => { 271 | let scope = nock('http://mysite.com') 272 | .get('/pl.m3u8') 273 | .replyWithError('Nooo'); 274 | let stream = m3u8stream('http://mysite.com/pl.m3u8', { 275 | requestOptions: { maxRetries: 0 } }); 276 | stream.on('error', err => { 277 | scope.done(); 278 | assert.equal(err.message, 'Nooo'); 279 | done(); 280 | }); 281 | stream.on('end', () => { 282 | throw Error('Should not emit end'); 283 | }); 284 | }); 285 | 286 | it('Stops on error refreshing playlist', done => { 287 | let scope = nock('https://priv.example.com') 288 | .get('/playlist.m3u8') 289 | .replyWithFile(200, path.resolve(__dirname, 290 | 'playlists/live-1.1.m3u8')) 291 | .get('/fileSequence2681.ts') 292 | .reply(200, 'one') 293 | .get('/fileSequence2682.ts') 294 | .reply(200, 'two') 295 | .get('/fileSequence2683.ts') 296 | .reply(200, 'three') 297 | .get('/playlist.m3u8') 298 | .replyWithError('uh oh'); 299 | 300 | let stream = m3u8stream('https://priv.example.com/playlist.m3u8', { 301 | requestOptions: { maxRetries: 0 } }); 302 | stream.on('error', err => { 303 | scope.done(); 304 | assert.equal(err.message, 'uh oh'); 305 | done(); 306 | }); 307 | stream.on('end', () => { 308 | throw Error('Should not emit end'); 309 | }); 310 | }); 311 | 312 | it('Stops on error getting a segment', done => { 313 | let scope = nock('https://priv.example.com') 314 | .get('/playme.m3u8') 315 | .replyWithFile(200, path.resolve(__dirname, 316 | 'playlists/live-1.1.m3u8')) 317 | .get('/fileSequence2681.ts') 318 | .reply(200, 'hello') 319 | .get('/fileSequence2682.ts') 320 | .replyWithError('bad segment'); 321 | let stream = m3u8stream('https://priv.example.com/playme.m3u8', { 322 | chunkReadahead: 1, 323 | requestOptions: { maxRetries: 0 }, 324 | }); 325 | stream.on('error', err => { 326 | assert.equal(err.message, 'bad segment'); 327 | scope.done(); 328 | done(); 329 | }); 330 | stream.on('end', () => { 331 | throw Error('Should not emit end'); 332 | }); 333 | }); 334 | 335 | it('Handles retrieving same live playlist twice', done => { 336 | let scope = nock('https://priv.example.com') 337 | .get('/playlist.m3u8') 338 | .replyWithFile(200, path.resolve(__dirname, 339 | 'playlists/live-1.1.m3u8')) 340 | .get('/fileSequence2681.ts') 341 | .reply(200, 'apple') 342 | .get('/fileSequence2682.ts') 343 | .reply(200, 'banana') 344 | .get('/fileSequence2683.ts') 345 | .reply(200, 'cherry') 346 | .get('/playlist.m3u8') 347 | .replyWithFile(200, path.resolve(__dirname, 348 | 'playlists/live-1.1.m3u8')) 349 | .get('/playlist.m3u8') 350 | .replyWithFile(200, path.resolve(__dirname, 351 | 'playlists/live-1.2.m3u8')) 352 | .get('/fileSequence2684.ts') 353 | .reply(200, 'fig') 354 | .get('/fileSequence2685.ts') 355 | .reply(200, 'grape'); 356 | 357 | let stream = m3u8stream('https://priv.example.com/playlist.m3u8'); 358 | concat(stream, (err, body) => { 359 | assert.ifError(err); 360 | scope.done(); 361 | assert.equal(body, [ 362 | 'apple', 363 | 'banana', 364 | 'cherry', 365 | 'fig', 366 | 'grape', 367 | ].join('')); 368 | done(); 369 | }); 370 | }); 371 | 372 | describe('With dated segments', () => { 373 | describe('With `begin` set to now', () => { 374 | it('Starts stream on segment that matches `begin`', done => { 375 | let scope = nock('https://yt.com') 376 | .get('/playlist.m3u8') 377 | .replyWithFile(200, path.resolve(__dirname, 378 | 'playlists/youtube-live-1.1.m3u8')) 379 | .get('/fileSequence0005.ts') 380 | .reply(200, '05') 381 | .get('/fileSequence0006.ts') 382 | .reply(200, '06') 383 | .get('/fileSequence0007.ts') 384 | .reply(200, '07') 385 | .get('/fileSequence0008.ts') 386 | .reply(200, '08') 387 | .get('/playlist.m3u8') 388 | .replyWithFile(200, path.resolve(__dirname, 389 | 'playlists/youtube-live-1.2.m3u8')) 390 | .get('/fileSequence0009.ts') 391 | .reply(200, '09') 392 | .get('/fileSequence0010.ts') 393 | .reply(200, '10') 394 | .get('/fileSequence0011.ts') 395 | .reply(200, '11') 396 | .get('/fileSequence0012.ts') 397 | .reply(200, '12'); 398 | 399 | let stream = m3u8stream('https://yt.com/playlist.m3u8', { 400 | begin: Date.now(), 401 | }); 402 | concat(stream, (err, body) => { 403 | assert.ifError(err); 404 | scope.done(); 405 | assert.equal(body, [ 406 | '05', 407 | '06', 408 | '07', 409 | '08', 410 | '09', 411 | '10', 412 | '11', 413 | '12', 414 | ].join('')); 415 | done(); 416 | }); 417 | }); 418 | }); 419 | 420 | describe('With `begin` set using relative format', () => { 421 | it('Starts stream on segment that matches `begin`', done => { 422 | let scope = nock('https://yt.com') 423 | .get('/playlist.m3u8') 424 | .replyWithFile(200, path.resolve(__dirname, 425 | 'playlists/youtube-live-1.1.m3u8')) 426 | .get('/fileSequence0003.ts') 427 | .reply(200, '03') 428 | .get('/fileSequence0004.ts') 429 | .reply(200, '04') 430 | .get('/fileSequence0005.ts') 431 | .reply(200, '05') 432 | .get('/fileSequence0006.ts') 433 | .reply(200, '06') 434 | .get('/fileSequence0007.ts') 435 | .reply(200, '07') 436 | .get('/fileSequence0008.ts') 437 | .reply(200, '08') 438 | .get('/playlist.m3u8') 439 | .replyWithFile(200, path.resolve(__dirname, 440 | 'playlists/youtube-live-1.2.m3u8')) 441 | .get('/fileSequence0009.ts') 442 | .reply(200, '09') 443 | .get('/fileSequence0010.ts') 444 | .reply(200, '10') 445 | .get('/fileSequence0011.ts') 446 | .reply(200, '11') 447 | .get('/fileSequence0012.ts') 448 | .reply(200, '12'); 449 | 450 | let stream = m3u8stream('https://yt.com/playlist.m3u8', { begin: '10s' }); 451 | concat(stream, (err, body) => { 452 | assert.ifError(err); 453 | scope.done(); 454 | assert.equal(body, [ 455 | '03', 456 | '04', 457 | '05', 458 | '06', 459 | '07', 460 | '08', 461 | '09', 462 | '10', 463 | '11', 464 | '12', 465 | ].join('')); 466 | done(); 467 | }); 468 | }); 469 | }); 470 | }); 471 | 472 | describe('Destroy stream', () => { 473 | describe('Right away', () => { 474 | it('Ends stream right away with no data', done => { 475 | let stream = m3u8stream('https://whatever.com/playlist.m3u8'); 476 | concat(stream, (err, body) => { 477 | assert.ifError(err); 478 | assert.equal(body, ''); 479 | done(); 480 | }); 481 | stream.end(); 482 | }); 483 | }); 484 | 485 | describe('In the middle of the segments list', () => { 486 | it('Stops stream from emitting more data and ends it', done => { 487 | let scope = nock('https://priv.example.com') 488 | .get('/playlist.m3u8') 489 | .replyWithFile(200, path.resolve(__dirname, 490 | 'playlists/live-2.1.m3u8')) 491 | .get('/fileSequence2681.ts') 492 | .reply(200, 'apple') 493 | .get('/fileSequence2682.ts') 494 | .reply(200, 'banana') 495 | .get('/fileSequence2683.ts') 496 | .reply(200, 'cherry') 497 | .get('/fileSequence2684.ts') 498 | .reply(200, 'durango') 499 | .get('/fileSequence2685.ts') 500 | .reply(200, 'whatever'); 501 | let stream = m3u8stream('https://priv.example.com/playlist.m3u8', { 502 | chunkReadahead: 1, 503 | }); 504 | stream.on('progress', ({ num }) => { 505 | if (num === 5) { 506 | stream.end(); 507 | } 508 | }); 509 | concat(stream, (err, body) => { 510 | assert.ifError(err); 511 | scope.done(); 512 | assert.equal(body, [ 513 | 'apple', 514 | 'banana', 515 | 'cherry', 516 | 'durango', 517 | 'whatever', 518 | ].join('')); 519 | done(); 520 | }); 521 | }); 522 | }); 523 | }); 524 | }); 525 | 526 | describe('DASH MPD playlist', () => { 527 | it('Concatenates egments into stream', done => { 528 | let scope = nock('https://videohost.com') 529 | .get('/playlist.mpd') 530 | .replyWithFile(200, path.resolve(__dirname, 531 | 'playlists/multi-representation.mpd')) 532 | .get('/134/0001.ts') 533 | .reply(200, '01') 534 | .get('/134/0002.ts') 535 | .reply(200, '02') 536 | .get('/134/0003.ts') 537 | .reply(200, '03') 538 | .get('/134/0004.ts') 539 | .reply(200, '04') 540 | .get('/134/0005.ts') 541 | .reply(200, '05') 542 | .get('/134/0006.ts') 543 | .reply(200, '06') 544 | .get('/134/0007.ts') 545 | .reply(200, '07') 546 | .get('/134/0008.ts') 547 | .reply(200, '08') 548 | .get('/134/0009.ts') 549 | .reply(200, '09') 550 | .get('/134/0010.ts') 551 | .reply(200, '10'); 552 | let stream = m3u8stream('https://videohost.com/playlist.mpd', { 553 | id: '134', 554 | }); 555 | concat(stream, (err, body) => { 556 | assert.ifError(err); 557 | scope.done(); 558 | assert.equal(body, [ 559 | '01', 560 | '02', 561 | '03', 562 | '04', 563 | '05', 564 | '06', 565 | '07', 566 | '08', 567 | '09', 568 | '10', 569 | ].join('')); 570 | done(); 571 | }); 572 | }); 573 | }); 574 | 575 | describe('m3u8 playlist with ranges', () => { 576 | it('Makes ranged requests', done => { 577 | let filename = path.resolve(__dirname, 'playlists/main.mp4'); 578 | function replyWithRange(this: nock.ReplyFnContext) { 579 | /* eslint no-invalid-this: off */ 580 | const range = this.req.headers.range; 581 | assert.ok(range); 582 | const rangeMatch = range.match(/bytes=(\d+)-(\d+)/); 583 | if (!rangeMatch) { 584 | throw Error(`Bad range: ${range}`); 585 | } 586 | return fs.createReadStream(filename, { 587 | start: parseInt(rangeMatch[1]), end: parseInt(rangeMatch[2]), 588 | }); 589 | } 590 | let scope = nock('https://somethingsomething.fyi') 591 | .get('/playlist.m3u8') 592 | .replyWithFile(200, path.resolve(__dirname, 593 | 'playlists/x-byterange-1.m3u8')) 594 | .get('/main.mp4') 595 | .reply(200, replyWithRange) 596 | .get('/main.mp4') 597 | .reply(200, replyWithRange) 598 | .get('/main.mp4') 599 | .reply(200, replyWithRange) 600 | .get('/main.mp4') 601 | .reply(200, replyWithRange) 602 | .get('/main.mp4') 603 | .reply(200, replyWithRange); 604 | let stream = m3u8stream('https://somethingsomething.fyi/playlist.m3u8'); 605 | let segments: m3u8stream.Progress[] = []; 606 | stream.on('progress', segment => segments.push(segment)); 607 | concat(stream, (err, body) => { 608 | assert.ifError(err); 609 | scope.done(); 610 | assert.deepEqual(segments, [ 611 | { url: 'main.mp4', 612 | num: 1, size: 50, duration: 0 }, 613 | { url: 'main.mp4', 614 | num: 2, size: 75, duration: 4969 }, 615 | { url: 'main.mp4', 616 | num: 3, size: 70, duration: 4969 }, 617 | { url: 'main.mp4', 618 | num: 4, size: 70, duration: 4969 }, 619 | { url: 'main.mp4', 620 | num: 5, size: 80, duration: 4969 }, 621 | ]); 622 | assert.equal(body, fs.readFileSync(filename, 'utf8')); 623 | done(); 624 | }); 625 | }); 626 | }); 627 | 628 | describe('With a bad parser', () => { 629 | it('Throws bad parser error', () => { 630 | assert.throws(() => { 631 | m3u8stream('http://media.example.com/playlist.m3u8', { 632 | // @ts-ignore 633 | parser: 'baaaaad', 634 | }); 635 | }, /parser '\w+' not supported/); 636 | }); 637 | }); 638 | 639 | describe('Exposes parse-time', () => { 640 | assert.equal(m3u8stream.parseTimestamp, humanStr); 641 | }); 642 | }); 643 | -------------------------------------------------------------------------------- /test/parse-time-test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { humanStr } from '../dist/parse-time'; 3 | 4 | 5 | describe('parse-time', () => { 6 | it('Time format 00:00:00.000', () => { 7 | assert.equal(humanStr('25.000'), 25000); 8 | assert.equal(humanStr('05:30'), (60000 * 5) + 30000); 9 | assert.equal(humanStr('01:05:30'), (60000 * 60) + (60000 * 5) + 30000); 10 | assert.equal(humanStr('1:30.123'), 60000 + 30000 + 123); 11 | }); 12 | 13 | it('Time format 0ms, 0s, 0m, 0h', () => { 14 | assert.equal(humanStr('2ms'), 2); 15 | assert.equal(humanStr('1m'), 60000); 16 | assert.equal(humanStr('1m10s'), 60000 + 10000); 17 | assert.equal(humanStr('2hm10s500ms'), (3600000 * 2) + 10000 + 500); 18 | }); 19 | 20 | it('No format', () => { 21 | assert.equal(humanStr('1000'), 1000); 22 | assert.equal(humanStr(200), 200); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/playlists/encrypted.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:3 3 | #EXT-X-MEDIA-SEQUENCE:7794 4 | #EXT-X-TARGETDURATION:15 5 | 6 | #EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=52" 7 | 8 | #EXTINF:2.833, 9 | http://media.example.com/fileSequence52-A.ts 10 | #EXTINF:15.0, 11 | http://media.example.com/fileSequence52-B.ts 12 | #EXTINF:13.333, 13 | http://media.example.com/fileSequence52-C.ts 14 | 15 | #EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=53" 16 | 17 | #EXTINF:15.0, 18 | http://media.example.com/fileSequence53-A.ts 19 | -------------------------------------------------------------------------------- /test/playlists/example.mpd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ad/ 6 | 7 | 8 | 9 | 10 | 11 | 720p.ts 12 | 13 | 14 | 15 | 16 | 17 | 18 | 1080p.ts 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | main/ 28 | 29 | 30 | video/ 31 | 32 | 33 | 720p/ 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 1080/ 53 | 54 | 55 | 56 | 58 | 59 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | audio/ 69 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /test/playlists/facebook.mpd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | 23 | 28 | 39 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 69 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 97 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | -------------------------------------------------------------------------------- /test/playlists/live-1.1.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:3 3 | #EXT-X-TARGETDURATION:8 4 | #EXT-X-MEDIA-SEQUENCE:2681 5 | 6 | #EXTINF:7.975, 7 | https://priv.example.com/fileSequence2681.ts 8 | #EXTINF:7.941, 9 | https://priv.example.com/fileSequence2682.ts 10 | #EXTINF:7.975, 11 | https://priv.example.com/fileSequence2683.ts 12 | -------------------------------------------------------------------------------- /test/playlists/live-1.2.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:3 3 | #EXT-X-TARGETDURATION:8 4 | #EXT-X-MEDIA-SEQUENCE:2683 5 | 6 | #EXTINF:7.975, 7 | https://priv.example.com/fileSequence2683.ts 8 | #EXTINF:7.941, 9 | https://priv.example.com/fileSequence2684.ts 10 | #EXTINF:7.941, 11 | https://priv.example.com/fileSequence2685.ts 12 | #EXT-X-ENDLIST 13 | -------------------------------------------------------------------------------- /test/playlists/live-2.1.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:3 3 | #EXT-X-TARGETDURATION:8 4 | #EXT-X-MEDIA-SEQUENCE:2681 5 | 6 | #EXTINF:7.975, 7 | https://priv.example.com/fileSequence2681.ts 8 | #EXTINF:7.941, 9 | https://priv.example.com/fileSequence2682.ts 10 | #EXTINF:7.975, 11 | https://priv.example.com/fileSequence2683.ts 12 | #EXTINF:7.975, 13 | https://priv.example.com/fileSequence2684.ts 14 | #EXTINF:7.941, 15 | https://priv.example.com/fileSequence2685.ts 16 | #EXTINF:7.975, 17 | https://priv.example.com/fileSequence2686.ts 18 | #EXTINF:7.975, 19 | https://priv.example.com/fileSequence2687.ts 20 | #EXTINF:7.941, 21 | https://priv.example.com/fileSequence2688.ts 22 | #EXTINF:7.975, 23 | https://priv.example.com/fileSequence2689.ts 24 | #EXTINF:7.975, 25 | https://priv.example.com/fileSequence2690.ts 26 | -------------------------------------------------------------------------------- /test/playlists/live-2.2.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:3 3 | #EXT-X-TARGETDURATION:8 4 | #EXT-X-MEDIA-SEQUENCE:2691 5 | 6 | #EXTINF:7.975, 7 | https://priv.example.com/fileSequence2691.ts 8 | #EXTINF:7.941, 9 | https://priv.example.com/fileSequence2692.ts 10 | #EXTINF:7.975, 11 | https://priv.example.com/fileSequence2693.ts 12 | #EXTINF:7.975, 13 | https://priv.example.com/fileSequence2694.ts 14 | #EXTINF:7.941, 15 | https://priv.example.com/fileSequence2695.ts 16 | #EXTINF:7.975, 17 | https://priv.example.com/fileSequence2696.ts 18 | #EXTINF:7.975, 19 | https://priv.example.com/fileSequence2697.ts 20 | #EXTINF:7.941, 21 | https://priv.example.com/fileSequence2698.ts 22 | #EXTINF:7.975, 23 | https://priv.example.com/fileSequence2699.ts 24 | #EXTINF:7.975, 25 | https://priv.example.com/fileSequence2700.ts 26 | #EXT-X-ENDLIST 27 | -------------------------------------------------------------------------------- /test/playlists/main.mp4: -------------------------------------------------------------------------------- 1 | # ------- init --------- 2 | # ----- 50 bytes ------- 3 | 4 | # ------ first --------- 5 | # ----- segment -------- 6 | # -------- :) ---------- 7 | 8 | # ------ second ------- 9 | # ----- segment ------- 10 | # -------- :| --------- 11 | 12 | # ------ third -------- 13 | # ----- segment ------- 14 | # -------- ._. -------- 15 | 16 | # ------ fourth ------- 17 | # ----- segment ------- 18 | # -------- O_O -------- 19 | -------------------------------------------------------------------------------- /test/playlists/master.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-STREAM-INF:BANDWIDTH=1280000,AVERAGE-BANDWIDTH=1000000 3 | http://example.com/low.m3u8 4 | #EXT-X-STREAM-INF:BANDWIDTH=2560000,AVERAGE-BANDWIDTH=2000000 5 | http://example.com/mid.m3u8 6 | #EXT-X-STREAM-INF:BANDWIDTH=7680000,AVERAGE-BANDWIDTH=6000000 7 | http://example.com/hi.m3u8 8 | #EXT-X-STREAM-INF:BANDWIDTH=65000,CODECS="mp4a.40.5" 9 | http://example.com/audio-only.m3u8 10 | -------------------------------------------------------------------------------- /test/playlists/multi-representation.mpd: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | https://videohost.com/139/ 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | https://videohost.com/140/ 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | https://videohost.com/133/ 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | https://videohost.com/134/ 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /test/playlists/segment-template-2.mpd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /test/playlists/segment-template.mpd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | media/ 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /test/playlists/simple.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-TARGETDURATION:10 3 | #EXTINF:9.009, 4 | http://media.example.com/first.ts 5 | #EXTINF:9.009, 6 | http://media.example.com/second.ts 7 | #EXTINF:3.003, 8 | http://media.example.com/third.ts 9 | #EXT-X-ENDLIST 10 | -------------------------------------------------------------------------------- /test/playlists/simple.mpd: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | https://videohost.com/139/ 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /test/playlists/simple_relative.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-TARGETDURATION:10 3 | #EXTINF:9.009, 4 | first.ts 5 | #EXTINF:9.009, 6 | second.ts 7 | #EXTINF:3.003, 8 | third.ts 9 | #EXT-X-ENDLIST 10 | -------------------------------------------------------------------------------- /test/playlists/twitch-1.1.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:3 3 | #EXT-X-TARGETDURATION:10 4 | #ID3-EQUIV-TDTG:2019-11-10T01:29:39 5 | #EXT-X-PLAYLIST-TYPE:EVENT 6 | #EXT-X-MEDIA-SEQUENCE:0 7 | #EXT-X-TWITCH-ELAPSED-SECS:0.000 8 | #EXT-X-TWITCH-TOTAL-SECS:150.000 9 | #EXTINF:10.000, 10 | 0.ts 11 | #EXTINF:10.000, 12 | 1.ts 13 | #EXTINF:10.000, 14 | 2.ts 15 | #EXTINF:10.000, 16 | 3.ts 17 | #EXTINF:10.000, 18 | 4.ts 19 | #EXTINF:10.000, 20 | 5.ts 21 | #EXTINF:10.000, 22 | 6.ts 23 | #EXTINF:10.000, 24 | 7.ts 25 | #EXTINF:10.000, 26 | 8.ts 27 | #EXTINF:10.000, 28 | 9.ts 29 | #EXTINF:10.000, 30 | 10.ts 31 | #EXTINF:10.000, 32 | 11.ts 33 | #EXTINF:10.000, 34 | 12.ts 35 | #EXTINF:10.000, 36 | 13.ts 37 | #EXTINF:10.000, 38 | 14.ts 39 | #EXTINF:10.000, 40 | 15.ts 41 | #EXT-X-ENDLIST 42 | 43 | -------------------------------------------------------------------------------- /test/playlists/x-byterange-1.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-TARGETDURATION:5 3 | #EXT-X-VERSION:7 4 | #EXT-X-MEDIA-SEQUENCE:1 5 | #EXT-X-PLAYLIST-TYPE:VOD 6 | #EXT-X-KEY:METHOD=AES-128,URI="./slow_loading.php?delay=5&resource=crypt0.key",IV=0xbf9840dc7d7fa163301a6c38844d6239 7 | #EXT-X-MAP:URI="main.mp4",BYTERANGE="50@0" 8 | #EXTINF:4.96907, 9 | #EXT-X-BYTERANGE:75@50 10 | main.mp4 11 | #EXTINF:4.96907, 12 | #EXT-X-BYTERANGE:70 13 | main.mp4 14 | #EXTINF:4.96907, 15 | #EXT-X-BYTERANGE:70@195 16 | main.mp4 17 | #EXTINF:4.96907, 18 | #EXT-X-BYTERANGE:80 19 | main.mp4 20 | #EXT-X-ENDLIST 21 | -------------------------------------------------------------------------------- /test/playlists/x-map-1.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-TARGETDURATION:5 3 | #EXT-X-VERSION:7 4 | #EXT-X-MEDIA-SEQUENCE:1 5 | #EXT-X-PLAYLIST-TYPE:VOD 6 | #EXT-X-KEY:METHOD=AES-128,URI="./slow_loading.php?delay=5&resource=crypt0.key",IV=0xbf9840dc7d7fa163301a6c38844d6239 7 | #EXT-X-MAP:URI="init.mp4" 8 | #EXTINF:4.96907, 9 | main1.mp4 10 | #EXTINF:4.96907, 11 | main2.mp4 12 | #EXTINF:4.96907, 13 | main3.mp4 14 | #EXTINF:4.96907, 15 | main4.mp4 16 | #EXT-X-ENDLIST 17 | -------------------------------------------------------------------------------- /test/playlists/x-map-2.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-TARGETDURATION:5 3 | #EXT-X-VERSION:7 4 | #EXT-X-MEDIA-SEQUENCE:1 5 | #EXT-X-PLAYLIST-TYPE:VOD 6 | #EXT-X-KEY:METHOD=AES-128,URI="./slow_loading.php?delay=5&resource=crypt0.key",IV=0xbf9840dc7d7fa163301a6c38844d6239 7 | #EXT-X-MAP:BYTERANGE=560@0 8 | #EXTINF:4.96907, 9 | #EXT-X-BYTERANGE:25312@560 10 | main.mp4 11 | #EXTINF:4.96907, 12 | #EXT-X-BYTERANGE:25440@25872 13 | main.mp4 14 | #EXTINF:4.96907, 15 | #EXT-X-BYTERANGE:25440@51312 16 | main.mp4 17 | #EXTINF:4.96907, 18 | #EXT-X-BYTERANGE:25440@76752 19 | main.mp4 20 | #EXT-X-ENDLIST 21 | -------------------------------------------------------------------------------- /test/playlists/x-map-3.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-TARGETDURATION:5 3 | #EXT-X-VERSION:7 4 | #EXT-X-MEDIA-SEQUENCE:1 5 | #EXT-X-PLAYLIST-TYPE:VOD 6 | #EXT-X-KEY:METHOD=AES-128,URI="./slow_loading.php?delay=5&resource=crypt0.key",IV=0xbf9840dc7d7fa163301a6c38844d6239 7 | #EXT-X-MAP:URI="main.mp4",BYTERANGE="50@0" 8 | #EXTINF:4.96907, 9 | #EXT-X-BYTERANGE:75@50 10 | main.mp4 11 | #EXTINF:4.96907, 12 | #EXT-X-BYTERANGE:70@125 13 | main.mp4 14 | #EXT-X-MAP:URI="main.mp4",BYTERANGE="50" 15 | #EXTINF:4.96907, 16 | #EXT-X-BYTERANGE:70@245 17 | main.mp4 18 | #EXTINF:4.96907, 19 | #EXT-X-BYTERANGE:80@315 20 | main.mp4 21 | #EXT-X-ENDLIST 22 | -------------------------------------------------------------------------------- /test/playlists/youtube-live-1.1.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:3 3 | #EXT-X-TARGETDURATION:5 4 | #EXT-X-MEDIA-SEQUENCE:0 5 | #EXT-X-PROGRAM-DATE-TIME:2018-06-12T22:34:06.340+00:00 6 | #EXTINF:3.67, 7 | https://yt.com/fileSequence0000.ts 8 | #EXTINF:5.005, 9 | https://yt.com/fileSequence0001.ts 10 | #EXTINF:5.038, 11 | https://yt.com/fileSequence0002.ts 12 | #EXTINF:4.972, 13 | https://yt.com/fileSequence0003.ts 14 | #EXTINF:5.005, 15 | https://yt.com/fileSequence0004.ts 16 | #EXTINF:5.005, 17 | https://yt.com/fileSequence0005.ts 18 | #EXTINF:5.005, 19 | https://yt.com/fileSequence0006.ts 20 | #EXTINF:5.005, 21 | https://yt.com/fileSequence0007.ts 22 | #EXTINF:5.005, 23 | https://yt.com/fileSequence0008.ts 24 | -------------------------------------------------------------------------------- /test/playlists/youtube-live-1.2.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:3 3 | #EXT-X-TARGETDURATION:5 4 | #EXT-X-MEDIA-SEQUENCE:0 5 | #EXT-X-PROGRAM-DATE-TIME:2018-06-12T22:34:06.340+00:00 6 | #EXTINF:3.67, 7 | https://yt.com/fileSequence0000.ts 8 | #EXTINF:5.005, 9 | https://yt.com/fileSequence0001.ts 10 | #EXTINF:5.038, 11 | https://yt.com/fileSequence0002.ts 12 | #EXTINF:4.972, 13 | https://yt.com/fileSequence0003.ts 14 | #EXTINF:5.005, 15 | https://yt.com/fileSequence0004.ts 16 | #EXTINF:5.005, 17 | https://yt.com/fileSequence0005.ts 18 | #EXTINF:5.005, 19 | https://yt.com/fileSequence0006.ts 20 | #EXTINF:5.005, 21 | https://yt.com/fileSequence0007.ts 22 | #EXTINF:5.005, 23 | https://yt.com/fileSequence0008.ts 24 | #EXTINF:5.005, 25 | https://yt.com/fileSequence0009.ts 26 | #EXTINF:5.005, 27 | https://yt.com/fileSequence0010.ts 28 | #EXTINF:5.005, 29 | https://yt.com/fileSequence0011.ts 30 | #EXTINF:5.005, 31 | https://yt.com/fileSequence0012.ts 32 | #EXT-X-ENDLIST 33 | -------------------------------------------------------------------------------- /test/queue-test.ts: -------------------------------------------------------------------------------- 1 | import { Queue, Callback } from '../dist/queue'; 2 | import assert from 'assert'; 3 | import sinon from 'sinon'; 4 | 5 | 6 | describe('Create a queue', () => { 7 | describe('With 3 concurrency', () => { 8 | let clock: sinon.SinonFakeTimers; 9 | before(() => { clock = sinon.useFakeTimers(); }); 10 | after(() => { clock.uninstall(); }); 11 | 12 | it('Defaults to a set concurrency', done => { 13 | let maxms: number; 14 | let lastTask: number; 15 | let q = new Queue((task: number, callback: Callback) => { 16 | if (lastTask) { 17 | // Make sure tasks are called in order. 18 | // Even if they don't finish in order. 19 | assert.equal(lastTask, task - 1); 20 | } 21 | lastTask = task; 22 | let ms = Math.floor(Math.random() * 1000); 23 | setTimeout(() => { callback(null); }, ms); 24 | if (!maxms || ms > maxms) { 25 | maxms = ms; 26 | } 27 | }, { concurrency: 3 }); 28 | 29 | let total = 10, called = 0; 30 | const callback = () => { 31 | if (++called === total) { 32 | done(); 33 | } 34 | process.nextTick(() => { clock.tick(maxms); }); 35 | }; 36 | 37 | for (let i = 0; i < total; i++) { 38 | q.push(i, callback); 39 | assert.ok(q.active <= 3); 40 | } 41 | process.nextTick(() => { clock.tick(maxms); }); 42 | }); 43 | }); 44 | 45 | describe('With 1 concurrency', () => { 46 | it('Runs tasks sequentially one at a time', done => { 47 | let q = new Queue((task: number, callback: Callback) => { 48 | assert.equal(q.active, 1); 49 | process.nextTick(() => { callback(null); }); 50 | }, { concurrency: 1 }); 51 | 52 | let total = 5, called = 0; 53 | const callback = () => { 54 | if (++called === total) { done(); } 55 | }; 56 | for (let i = 0; i < total; i++) { 57 | q.push(i, callback); 58 | assert.equal(q.active, 1); 59 | } 60 | }); 61 | }); 62 | 63 | describe('Call worker callback twice', () => { 64 | it('Calls task callback once', done => { 65 | let q = new Queue((task: unknown, callback: Callback) => { 66 | // Intentionally call callback twice. 67 | process.nextTick(() => { 68 | callback(null); 69 | callback(null); 70 | }); 71 | }); 72 | q.push({ mytask: 'hello' }, done); 73 | }); 74 | }); 75 | 76 | describe('Kill it halfway', () => { 77 | it('Does not run additional tasks', done => { 78 | let results: string[] = []; 79 | let q = new Queue((task: string, callback: Callback) => { 80 | results.push(task); 81 | process.nextTick(() => { 82 | callback(null); 83 | }); 84 | }, { concurrency: 2 }); 85 | q.push('a'); 86 | q.push('b'); 87 | q.push('hello'); 88 | q.push('2u'); 89 | q.push('and 2 me'); 90 | assert.equal(q.active, 2); 91 | assert.equal(q.tasks.length, 3); 92 | q.die(); 93 | assert.equal(q.tasks.length, 0); 94 | process.nextTick(() => { 95 | assert.equal(q.active, 0); 96 | assert.deepEqual(results, ['a', 'b']); 97 | done(); 98 | }); 99 | }); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "sourceMap": true, 6 | "outDir": "./dist" 7 | }, 8 | "files": [ 9 | "src/index.ts" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "strict": true, 6 | "noImplicitAny": true, 7 | "strictNullChecks": true, 8 | "strictFunctionTypes": true, 9 | "strictBindCallApply": true, 10 | "strictPropertyInitialization": false, 11 | "noImplicitThis": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": false, 14 | "alwaysStrict": true, 15 | "esModuleInterop": true, 16 | "outDir": "spec" 17 | } 18 | } --------------------------------------------------------------------------------