├── .tool-versions ├── AUTHORS ├── .husky ├── commit-msg └── pre-commit ├── assets ├── logo.png └── favicon.png ├── commitlint.config.js ├── .lintstagedrc ├── .prettierrc ├── .editorconfig ├── src ├── index.js ├── exceptions │ ├── index.js │ ├── InvalidPlaylist.js │ └── ProtocolNotSupported.js ├── utils │ ├── index.js │ └── utils.js └── Downloader.js ├── tests ├── fixtures │ ├── 6000kbit.m3u8 │ └── masterPlaylist.m3u8 ├── utils.test.js └── downloader.test.js ├── eslint.config.js ├── .github ├── auto_assign.yml ├── workflows │ ├── test.yaml │ ├── coveralls.yaml │ ├── docs.yaml │ └── release.yaml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE │ └── pull_request_template.md └── lock.yml ├── jest.config.js ├── .gitignore ├── LICENSE ├── example.js ├── jsdoc.json ├── CONTRIBUTING.md ├── package.json └── README.md /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 22 2 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Nur Rony 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npm run commitlint ${1} 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged && npm test 2 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nurrony/hlsdownloader/HEAD/assets/logo.png -------------------------------------------------------------------------------- /assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nurrony/hlsdownloader/HEAD/assets/favicon.png -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | extends: ['@commitlint/config-conventional'], 3 | }; 4 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | --- 2 | 'src/*.js|eslint.config.js|*.json': 3 | - npm run lint:fix 4 | - npm run lint 5 | - prettier --write 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "printWidth": 120, 4 | "semi": true, 5 | "singleQuote": true, 6 | "tabWidth": 2, 7 | "trailingComma": "es5" 8 | } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | quote_type = single 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | 3 | import HLSDownloader from './Downloader'; 4 | 5 | /** 6 | * @name HLSDownloader 7 | * @module HLSDownloader 8 | * @description 9 | *

HLSDownloader

10 | *

Main module for this package

11 | */ 12 | export default HLSDownloader; 13 | -------------------------------------------------------------------------------- /src/exceptions/index.js: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | 3 | import InvalidPlayList from './InvalidPlaylist'; 4 | import ProtocolNotSupported from './ProtocolNotSupported'; 5 | 6 | /** 7 | * @description 8 | *

HLSDownloaderException

9 | *

Modules for HLSDownloader Custom Exceptions

10 | * @module HLSDownloaderException 11 | * @name HLSDownloaderException 12 | */ 13 | export { InvalidPlayList, ProtocolNotSupported }; 14 | -------------------------------------------------------------------------------- /tests/fixtures/6000kbit.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-MEDIA-SEQUENCE:0 3 | #EXT-X-TARGETDURATION:2 4 | 5 | #EXTINF:2, 6 | 250kbit/seq-0.ts 7 | #EXTINF:2, 8 | 250kbit/seq-1.ts 9 | #EXTINF:2, 10 | 250kbit/seq-2.ts 11 | #EXTINF:2, 12 | 250kbit/seq-3.ts 13 | #EXTINF:2, 14 | 250kbit/seq-4.ts 15 | #EXTINF:2, 16 | 250kbit/seq-5.ts 17 | #EXTINF:2, 18 | 250kbit/seq-6.ts 19 | #EXTINF:2, 20 | 250kbit/seq-7.ts 21 | #EXTINF:2, 22 | 250kbit/seq-8.ts 23 | #EXTINF:2, 24 | 250kbit/seq-9.ts 25 | 26 | #EXT-X-ENDLIST 27 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | import { isValidPlaylist, isValidUrl, omit, parseUrl, stripFirstSlash, isNotFunction } from './utils'; 3 | 4 | /** 5 | * Utility module for HLSDownloader 6 | * @module HLSDownloaderUtils 7 | * @name HLSDownloaderUtils 8 | * @description 9 | *

HLSDownloaderUtils

10 | *

Utility module for HLSDownloader

11 | */ 12 | export default { isValidPlaylist, isValidUrl, omit, parseUrl, stripFirstSlash, isNotFunction }; 13 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from 'globals'; 2 | export default [ 3 | { 4 | files: ['**/*.js'], 5 | ignores: ['index.js', 'node_modules', 'extras/**', 'test/**', 'coverage', '.nyc-output'], 6 | languageOptions: { 7 | parserOptions: { 8 | ecmaFeatures: { 9 | impliedStrict: true, 10 | }, 11 | }, 12 | globals: { 13 | ...globals.node, 14 | ...globals.jest, 15 | }, 16 | ecmaVersion: 2023, 17 | sourceType: 'module', 18 | }, 19 | }, 20 | ]; 21 | -------------------------------------------------------------------------------- /.github/auto_assign.yml: -------------------------------------------------------------------------------- 1 | # Set to true to add reviewers to pull requests 2 | addReviewers: true 3 | 4 | # Set to true to add assignees to pull requests 5 | addAssignees: true 6 | 7 | # A list of reviewers to be added to pull requests (GitHub user name) 8 | reviewers: 9 | - nurrony 10 | 11 | # A list of keywords to be skipped the process that add reviewers if pull requests include it 12 | skipKeywords: 13 | - wip 14 | 15 | # A number of reviewers added to the pull request 16 | # Set 0 to add all the reviewers (default: 0) 17 | numberOfReviewers: 0 18 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | clearMocks: true, 3 | collectCoverage: true, 4 | coverageDirectory: 'coverage', 5 | coveragePathIgnorePatterns: ['/node_modules/', '/src/utils/index.js', '/src/lib/index.js', '/tests/mocks/'], 6 | coverageReporters: ['clover', 'html', 'text'], 7 | 8 | coverageThreshold: { 9 | global: { 10 | branches: 60, 11 | functions: 60, 12 | lines: 60, 13 | statements: 60, 14 | }, 15 | }, 16 | errorOnDeprecated: true, 17 | testEnvironment: 'node', 18 | transform: {}, 19 | testMatch: ['**/tests/specs/**/*.spec.js', '**/tests/**/*.js'], 20 | verbose: true, 21 | }; 22 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | unit_test: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | - name: Testing on Node.js 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: latest 24 | cache: 'npm' 25 | - name: Version Check 26 | run: node -v && npm -v 27 | - name: Install Dependencies 28 | run: npm ci 29 | - name: Running Test 30 | run: npm test 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '[BUG]: Please add title for the bug' 5 | labels: bug 6 | assignees: nurrony 7 | --- 8 | 9 | ## Prerequisites 10 | 11 | - [ ] Can you reproduce the problem in safe mode? 12 | - [ ] Are you running the latest version? 13 | - [ ] Did you perform a cursory search? 14 | 15 | ### Description 16 | 17 | // Description of the bug or feature 18 | 19 | ### Steps to Reproduce 20 | 21 | Required section 22 | 23 | **Expected behavior:** 24 | 25 | // What you expected to happen 26 | 27 | **Actual behavior:** 28 | 29 | // What actually happened 30 | 31 | ### System informations 32 | 33 | Good to have to debug and resolving the issue 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | 3 | ### Node template 4 | # Logs 5 | logs 6 | *.log 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | # Directory for instrumented libs generated by jscoverage/JSCover 13 | lib-cov 14 | 15 | # Coverage directory used by tools like istanbul 16 | coverage 17 | .nyc_output 18 | 19 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 20 | .grunt 21 | 22 | # node-waf configuration 23 | .lock-wscript 24 | 25 | # Compiled binary addons (http://nodejs.org/api/addons.html) 26 | build 27 | .idea 28 | # Dependency directory 29 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 30 | node_modules 31 | extras/** 32 | docs 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea, enhancement or suggestions for this project 4 | title: '[FEATURE]: please add title for your idea/suggestions' 5 | labels: enhancement, feautre 6 | assignees: nurrony 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /src/exceptions/InvalidPlaylist.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @class 3 | * @extends Error 4 | * @author Nur Rony 5 | * @memberof module:HLSDownloaderException 6 | * @classdesc Exception to throw if HLS playlist is invalid 7 | */ 8 | class InvalidPlaylist extends Error { 9 | /** 10 | * @constructor 11 | * @property {String} message message of exception 12 | */ 13 | constructor(message) { 14 | super(message); 15 | 16 | // assign the error class name in your custom error (as a shortcut) 17 | this.name = this.constructor.name; 18 | 19 | // capturing the stack trace keeps the reference to your error class 20 | Error.captureStackTrace(this, this.constructor); 21 | } 22 | } 23 | 24 | /** 25 | * @author Nur Rony 26 | * @classdesc Exception to throw if HLS playlist is invalid 27 | */ 28 | export default InvalidPlaylist; 29 | -------------------------------------------------------------------------------- /.github/workflows/coveralls.yaml: -------------------------------------------------------------------------------- 1 | name: Coverage Report 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | coverage: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | - name: Testing on Node.js 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: latest 24 | cache: 'npm' 25 | - name: Version Check 26 | run: node -v && npm -v 27 | - name: Install dependencies 28 | run: npm ci 29 | - name: Run Test 30 | run: npm test 31 | - name: Upload coverage reports to Coveralls 32 | uses: coverallsapp/github-action@v2 33 | env: 34 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} 35 | -------------------------------------------------------------------------------- /src/exceptions/ProtocolNotSupported.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @class 3 | * @memberof module:HLSDownloaderException 4 | * @extends Error 5 | * @author Nur Rony 6 | * @classdesc Exception to throw if HLSDownloader does not support the given URI protocol 7 | */ 8 | class ProtocolNotSupported extends Error { 9 | /** 10 | * @constructor 11 | * @property {String} message message of exception 12 | */ 13 | constructor(message) { 14 | super(message); 15 | 16 | // assign the error class name in your custom error (as a shortcut) 17 | this.name = this.constructor.name; 18 | 19 | // capturing the stack trace keeps the reference to your error class 20 | Error.captureStackTrace(this, this.constructor); 21 | } 22 | } 23 | 24 | /** 25 | * @author Nur Rony 26 | * @classdesc Exception to throw if HLSDownloader does not support the given URI protocol 27 | */ 28 | export default ProtocolNotSupported; 29 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/pull_request_template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Pull Request 3 | about: Create a report to help us improve 4 | title: 'Please add title for this PR' 5 | assignees: nurrony 6 | --- 7 | 8 | ## What does this PR do? 9 | 10 | // Please add some description here 11 | 12 | ## Are there points in the code the reviewer needs to double check? 13 | 14 | // Please add some brief description here 15 | 16 | ## Why was this PR needed? 17 | 18 | // Please add some brief description here 19 | 20 | ## Dependent PR Link 21 | 22 | // Please add these as list 23 | 24 | ## Screenshots (if relevant) 25 | 26 | // Please add some description here 27 | 28 | ## Does this MR meet the acceptance criteria? 29 | 30 | - [ ] Code Commenting 31 | - [ ] Documentation created/updated 32 | - [ ] Conform by the style guides 33 | - [ ] Unit Tests are added for this PR (if applicable) 34 | - [ ] [Squashed related commits together](https://git-scm.com/book/en/Git-Tools-Rewriting-History#Squashing-Commits) 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 2025 Nur Rony 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /.github/lock.yml: -------------------------------------------------------------------------------- 1 | # Configuration for lock-threads - https://github.com/dessant/lock-threads 2 | 3 | # Number of days of inactivity before a closed issue or pull request is locked 4 | daysUntilLock: 60 5 | 6 | # Issues and pull requests with these labels will not be locked. Set to `[]` to disable 7 | exemptLabels: [] 8 | 9 | # Label to add before locking, such as `outdated`. Set to `false` to disable 10 | lockLabel: 'outdated' 11 | 12 | # Comment to post before locking. Set to `false` to disable 13 | lockComment: > 14 | This thread has been automatically locked since there has not been 15 | any recent activity after it was closed. Please open a new issue for 16 | related bugs. 17 | 18 | # Assign `resolved` as the reason for locking. Set to `false` to disable 19 | setLockReason: true 20 | # Limit to only `issues` or `pulls` 21 | # only: issues 22 | 23 | # Optionally, specify configuration settings just for `issues` or `pulls` 24 | # issues: 25 | # exemptLabels: 26 | # - help-wanted 27 | # lockLabel: outdated 28 | 29 | # pulls: 30 | # daysUntilLock: 30 31 | 32 | # Repository to extend settings from 33 | # _extends: repo 34 | -------------------------------------------------------------------------------- /.github/workflows/docs.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Documentation 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | concurrency: 15 | group: 'pages' 16 | cancel-in-progress: false 17 | 18 | jobs: 19 | publish-docs: 20 | environment: 21 | name: hlsdownloader_docs_url 22 | url: ${{ steps.deployment.outputs.page_url }} 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v4 27 | with: 28 | fetch-depth: 0 29 | - name: Setup NodeJS 30 | uses: actions/setup-node@v4 31 | with: 32 | node-version: latest 33 | cache: 'npm' 34 | - name: Version Check 35 | run: node -v && npm -v 36 | - name: Install dependencies 37 | run: npm ci 38 | - name: 'Generate documents' 39 | run: npm run docs:gen 40 | - name: Setup Pages 41 | uses: actions/configure-pages@v4 42 | - name: Upload artifact 43 | uses: actions/upload-pages-artifact@v3 44 | with: 45 | path: './docs' 46 | - name: Deploy to GitHub Pages 47 | id: deployment 48 | uses: actions/deploy-pages@v4 49 | -------------------------------------------------------------------------------- /tests/fixtures/masterPlaylist.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | 3 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="stereo",LANGUAGE="en",NAME="English",DEFAULT=YES,AUTOSELECT=YES,URI="audio/stereo/en/128kbit.m3u8" 4 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="stereo",LANGUAGE="dubbing",NAME="Dubbing",DEFAULT=NO,AUTOSELECT=YES,URI="audio/stereo/none/128kbit.m3u8" 5 | 6 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="surround",LANGUAGE="en",NAME="English",DEFAULT=YES,AUTOSELECT=YES,URI="audio/surround/en/320kbit.m3u8" 7 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="surround",LANGUAGE="dubbing",NAME="Dubbing",DEFAULT=NO,AUTOSELECT=YES,URI="audio/stereo/none/128kbit.m3u8" 8 | 9 | #EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="Deutsch",DEFAULT=NO,AUTOSELECT=YES,FORCED=NO,LANGUAGE="de",URI="subtitles_de.m3u8" 10 | #EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="English",DEFAULT=YES,AUTOSELECT=YES,FORCED=NO,LANGUAGE="en",URI="subtitles_en.m3u8" 11 | #EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="Espanol",DEFAULT=NO,AUTOSELECT=YES,FORCED=NO,LANGUAGE="es",URI="subtitles_es.m3u8" 12 | #EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="Français",DEFAULT=NO,AUTOSELECT=YES,FORCED=NO,LANGUAGE="fr",URI="subtitles_fr.m3u8" 13 | 14 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=6214307,CODECS="avc1.4d4028,mp4a.40.2",AUDIO="surround",RESOLUTION=1921x818,SUBTITLES="subs" 15 | video/6000kbit.m3u8 16 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release to NPM 2 | 3 | on: 4 | workflow_run: 5 | workflows: ['CI'] 6 | types: 7 | - completed 8 | branches: 9 | - main 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | release: 16 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 17 | runs-on: ubuntu-latest 18 | permissions: 19 | issues: write 20 | id-token: write 21 | contents: write 22 | pull-requests: write 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v4 26 | with: 27 | fetch-depth: 0 28 | - name: Fetch all tags 29 | run: git pull --tags 30 | - name: List tags 31 | run: git tag -l 32 | - name: Setup Node.js 33 | uses: actions/setup-node@v4 34 | with: 35 | node-version: 'lts/*' 36 | - name: 'Node and NPM version' 37 | run: node -v && npm -v 38 | - name: Install dependencies 39 | run: npm clean-install 40 | - name: Verify the integrity of installed dependencies 41 | run: npm audit signatures 42 | - name: 'Build package' 43 | run: npm run build 44 | - name: Release 45 | run: npx semantic-release 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 49 | -------------------------------------------------------------------------------- /example.js: -------------------------------------------------------------------------------- 1 | import HLSDownloader from './build/index'; 2 | 3 | // for fetching 4 | let downloader = new HLSDownloader({ 5 | playlistURL: 'https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8', 6 | }); 7 | 8 | console.log(await downloader.startDownload()); 9 | 10 | // download HLS resoruces 11 | downloader = new HLSDownloader({ 12 | playlistURL: 'https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8', 13 | destination: '/tmp/tests', 14 | }); 15 | 16 | // with 5 parallel download 17 | downloader = new HLSDownloader({ 18 | concurrency: 5, 19 | destination: '/tmp/tests', 20 | playlistURL: 'https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8', 21 | }); 22 | 23 | // force overwrite 24 | downloader = new HLSDownloader({ 25 | concurrency: 5, 26 | overwrite: true, 27 | destination: '/tmp/tests', 28 | playlistURL: 'https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8', 29 | }); 30 | 31 | // passing ky option 32 | downloader = new HLSDownloader({ 33 | concurrency: 5, 34 | overwrite: true, 35 | destination: '/tmp/tests', 36 | playlistURL: 'https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8', 37 | retry: { limit: 0 }, 38 | }); 39 | 40 | // passing onData hook 41 | downloader = new HLSDownloader({ 42 | concurrency: 5, 43 | overwrite: true, 44 | destination: '/tmp/tests', 45 | playlistURL: 'https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8', 46 | retry: { limit: 0 }, 47 | onData: function (data) { 48 | console.log( 49 | 'downloaded item = ', 50 | data.url, 51 | ', total items to download', 52 | data.totalItems, 53 | ', downloaded path =', 54 | data.path 55 | ); 56 | }, 57 | }); 58 | 59 | // passing onError hook 60 | downloader = new HLSDownloader({ 61 | concurrency: 5, 62 | overwrite: true, 63 | destination: '/tmp/tests', 64 | playlistURL: 'https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8', 65 | retry: { limit: 0 }, 66 | onError: function (error) { 67 | console.log({ ...error }); 68 | }, 69 | }); 70 | -------------------------------------------------------------------------------- /jsdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "markdown": { 3 | "hardwrap": false, 4 | "idInHeadings": true 5 | }, 6 | "opts": { 7 | "destination": "docs", 8 | "encoding": "utf8", 9 | "private": true, 10 | "recurse": true, 11 | "template": "./node_modules/clean-jsdoc-theme", 12 | "theme_opts": { 13 | "create_style": "article ul { list-style: disc; }", 14 | "default_theme": "light", 15 | "displayModuleHeader": true, 16 | "exclude_inherited": false, 17 | "favicon": "./assets/favicon.png", 18 | "footer": "
HLSDownloader

Please Feel free to fork on GitHub. Give a ⭐️ if this project helped you!. I will be grateful if you all help me to improve this package by giving your suggestions, feature request and pull requests. I am all ears!!

", 19 | "homepageTitle": "Documentation | HLSDownloader", 20 | "includeFilesListInHomepage": true, 21 | "meta": [ 22 | { 23 | "content": "Nur Rony", 24 | "name": "Author" 25 | }, 26 | { 27 | "content": "Downloads HLS Playlist file and TS chunks", 28 | "name": "Description" 29 | } 30 | ], 31 | "prefixModuleToSidebarItems_experimental": false, 32 | "search": true, 33 | "static_dir": ["./assets"], 34 | "title": "" 35 | } 36 | }, 37 | "plugins": ["plugins/markdown"], 38 | "source": { 39 | "exclude": ["node_modules", "tests", "docs"], 40 | "include": ["README.md", "./src/"], 41 | "includePattern": ".+\\.(m?js(doc|x)?)$" 42 | }, 43 | "tags": { 44 | "allowUnknownTags": true 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to HLSDownloader 2 | 3 | Thank you for considering contributing to HLSDownloader! By participating in this project, you help make HLSDownloader better for everyone. Please take a moment to review this document to ensure a smooth and collaborative contribution process. 4 | 5 | ## Ways to Contribute 6 | 7 | There are several ways you can contribute to HLSDownloader: 8 | 9 | - Reporting issues 10 | - Suggesting enhancements 11 | - Submitting pull requests 12 | - Improving documentation 13 | - Sharing your experience using HLSDownloader 14 | 15 | ## Reporting Issues 16 | 17 | If you encounter a bug or have a feature request, please search the existing issues to avoid duplicating efforts. If your issue isn't already filed, feel free to [create a new issue](https://github.com/nurrony/hlsdownloader/issues) following the issue template. 18 | 19 | When reporting an issue, please provide: 20 | 21 | - A clear and descriptive title 22 | - Detailed steps to reproduce the problem 23 | - Any relevant error messages or logs 24 | - System and OS version 25 | 26 | ## Submitting Pull Requests 27 | 28 | We welcome and appreciate pull requests! If you're adding new features or fixing bugs, please fork the repository, create a new branch for your changes, and submit a pull request against the main branch. 29 | 30 | To help us review your PR effectively: 31 | 32 | - Explain the purpose of your changes 33 | - Provide context on any related issues 34 | - Keep the changes focused and limited in scope 35 | - Write clear commit messages. We follow [Conventional Commit Message](https://www.conventionalcommits.org/en/v1.0.0/) guidelines 36 | 37 | ## Coding Guidelines 38 | 39 | Please adhere to the coding standards and style used in the project. Make sure your code: 40 | 41 | - Follows the style guide (if available) 42 | - Includes tests for new functionality 43 | - Is well-documented and includes comments where necessary 44 | 45 | ## Review Process 46 | 47 | Pull requests will be reviewed by maintainers before merging. Feedback may be provided, and changes might be requested for improvements. Please be patient during the review process. 48 | 49 | ## Documentation 50 | 51 | Improving documentation is a valuable contribution. If you find areas lacking or confusing, feel free to update the documentation or create new documents to enhance clarity and usefulness. Use 52 | 53 | ## Licensing 54 | 55 | By contributing to HLSDownloader, you agree that your contributions will be licensed under the project's license. 56 | 57 | Thank you for your interest in improving HLSDownloader! Your contributions are highly appreciated. 58 | -------------------------------------------------------------------------------- /src/utils/utils.js: -------------------------------------------------------------------------------- 1 | import { ProtocolNotSupported } from './../exceptions'; 2 | 3 | /** 4 | * @memberof module:HLSDownloaderUtils 5 | * @author Nur Rony 6 | * @description Check if the url is valid 7 | * @param {string} url string to check 8 | * @param {string[]} [protocols] supported protocols 9 | * @returns {boolean} 10 | * @throws TypeError 11 | * @throws ProtocolNotSupported 12 | */ 13 | const isValidUrl = (url, protocols = ['http:', 'https:', 'ftp:', 'sftp:']) => { 14 | try { 15 | const { protocol } = new URL(url); 16 | if (protocol && !protocols.includes(`${protocol}`)) 17 | throw new ProtocolNotSupported(`${protocol} is not supported. Supported protocols are ${protocols.join(', ')}`); 18 | return true; 19 | } catch (error) { 20 | throw error; 21 | } 22 | }; 23 | 24 | /** 25 | * @memberof module:HLSDownloaderUtils 26 | * @author Nur Rony 27 | * @description Strip first slash from a url / path 28 | * @param {String} url URL to strip the slash 29 | * @return {String} Stripped url 30 | */ 31 | const stripFirstSlash = url => url.substring(0, 1).replace('/', '') + url.substring(1); 32 | 33 | /** 34 | * @memberof module:HLSDownloaderUtils 35 | * @author Nur Rony 36 | * @description Validate a Playlist 37 | * @param {string} playlistContent Content of playlist file 38 | * @returns {boolean} 39 | */ 40 | const isValidPlaylist = playlistContent => playlistContent.match(/^#EXTM3U/im) !== null; 41 | 42 | /** 43 | * @memberof module:HLSDownloaderUtils 44 | * @author Nur Rony 45 | * @description Validate a Playlist 46 | * @param {string} url url to parse 47 | * @returns {object} 48 | * @throws TypeError 49 | */ 50 | const parseUrl = url => new URL(url); 51 | 52 | /** 53 | * @memberof module:HLSDownloaderUtils 54 | * @author Nur Rony 55 | * @description omit given keys from an object. 56 | * @param {any} keys keys to remove from the object 57 | * @param {object} subject object to remove the keys form 58 | * @returns {object} 59 | */ 60 | const omit = (subject, ...keys) => { 61 | const keysToRemove = new Set(keys.flat()); 62 | return Object.fromEntries(Object.entries(subject).filter(([key]) => !keysToRemove.has(key))); 63 | }; 64 | 65 | /** 66 | * @memberof module:HLSDownloaderUtils 67 | * @author Nur Rony 68 | * Checks parameter is a function or not 69 | * @param {Function} fn function to validate 70 | * @returns {boolean} 71 | */ 72 | const isNotFunction = fn => typeof fn !== 'function'; 73 | 74 | /** 75 | * @memberof module:HLSDownloaderUtils 76 | * @author Nur Rony 77 | * @requires ./exceptions/ProtocolNotSupported.js 78 | */ 79 | export { isNotFunction, isValidPlaylist, isValidUrl, omit, parseUrl, stripFirstSlash }; 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": { 3 | "email": "pro.nmrony@gmail.com", 4 | "name": "Nur Rony", 5 | "url": "https://nurrony.github.io" 6 | }, 7 | "bugs": { 8 | "url": "https://github.com/nurrony/hlsdownloader/issues" 9 | }, 10 | "config": { 11 | "commitizen": { 12 | "path": "cz-conventional-changelog" 13 | } 14 | }, 15 | "dependencies": { 16 | "ky": "^1.14.0", 17 | "p-limit": "^7.2.0" 18 | }, 19 | "description": "Downloads HLS Playlist file and TS chunks", 20 | "devDependencies": { 21 | "@commitlint/cli": "^20.1.0", 22 | "@commitlint/config-conventional": "^20.0.0", 23 | "@types/jest": "^30.0.0", 24 | "clean-jsdoc-theme": "^4.3.0", 25 | "esbuild": "^0.27.0", 26 | "eslint": "^9.39.1", 27 | "husky": "^9.1.7", 28 | "jest": "^30.2.0", 29 | "jsdoc": "^4.0.5", 30 | "lint-staged": "^16.2.6", 31 | "prettier": "^3.6.2", 32 | "rimraf": "^6.1.0", 33 | "semantic-release": "25.0.2" 34 | }, 35 | "engines": { 36 | "node": ">=20", 37 | "npm": ">=9" 38 | }, 39 | "exports": { 40 | "default": "./build/index.js" 41 | }, 42 | "files": [ 43 | "build" 44 | ], 45 | "homepage": "https://nurrony.github.io/hlsdownloader", 46 | "hooks": { 47 | "pre-commit": "npm run commitlint ${1} && npm run lint:fix && npm run lint && npm test" 48 | }, 49 | "keywords": [ 50 | "cdn-priming", 51 | "chunk", 52 | "download", 53 | "download-playlists", 54 | "downloader", 55 | "HLS", 56 | "hlsdownloader", 57 | "live", 58 | "livestream", 59 | "m3u8", 60 | "m3u8downloader", 61 | "playlist", 62 | "streaming", 63 | "streaming-video" 64 | ], 65 | "license": "MIT", 66 | "main": "./build/index.js", 67 | "name": "hlsdownloader", 68 | "release": { 69 | "branches": [ 70 | "main" 71 | ], 72 | "debug": true 73 | }, 74 | "repository": { 75 | "type": "git+https", 76 | "url": "git+https://github.com/nurrony/hlsdownloader.git" 77 | }, 78 | "scripts": { 79 | "build": "npm run lint:fix && npm run lint && npm test && npm run build:clean && npm run compile && echo '📦 Build artifact has been generated successfully.'", 80 | "build:clean": "rimraf -fr build && echo '🧹 Build artifacts has been cleaned.'", 81 | "commitlint": "NODE_OPTIONS=--experimental-vm-modules npx commitlint --edit && echo '🔖 Commit message guidelines are followed properly.'", 82 | "compile": "npx esbuild --outdir=build --platform=node --format=esm --target=node18 --packages=external --bundle --minify --tree-shaking=true --keep-names src/index.js", 83 | "coverage": "NODE_OPTIONS=--experimental-vm-modules npx jest --coverage", 84 | "docs:clean": "rimraf -fr ./docs && echo '🧹 All docs has been cleaned.'", 85 | "docs:gen": "npm run docs:clean && NODE_OPTIONS=--experimental-vm-modules jsdoc -c jsdoc.json && echo '📄 Docs has been generated successfully.'", 86 | "example": "NODE_OPTIONS=--experimental-vm-modules node example.mjs", 87 | "lint": "eslint . && echo '💄 Coding style guideline has been followed properly.'", 88 | "lint:fix": "eslint . --fix && echo '🔧 Coding style has been fixed as per guideline.'", 89 | "prepare": "husky", 90 | "prepublishOnly": "npm run build", 91 | "prod:start": "node index.js", 92 | "semantic-release": "semantic-release", 93 | "test": "npm run test:coverage:clean && NODE_OPTIONS=--experimental-vm-modules npx jest", 94 | "test:coverage:clean": "rimraf -fr ./coverage && echo '🧹 All test coverage reports has been cleaned.'", 95 | "test:watch": "NODE_OPTIONS=--experimental-vm-modules npx jest --no-cache --watch", 96 | "version": "echo $npm_package_version" 97 | }, 98 | "snyk": true, 99 | "type": "module", 100 | "version": "0.0.0-semantic-release" 101 | } 102 | -------------------------------------------------------------------------------- /tests/utils.test.js: -------------------------------------------------------------------------------- 1 | import { URL } from 'url'; 2 | import { ProtocolNotSupported } from '../src/exceptions'; 3 | import Utils from './../src/utils'; 4 | 5 | describe('Utils', () => { 6 | describe('#isValidUrl', () => { 7 | test('should be a valid http url', () => { 8 | expect(Utils.isValidUrl('http://example.com')).toBeTruthy(); 9 | }); 10 | 11 | test('should be a valid https url', () => { 12 | expect(Utils.isValidUrl('https://example.com')).toBeTruthy(); 13 | }); 14 | 15 | test('should be a valid http url with username and password', () => { 16 | expect(Utils.isValidUrl('http://hello:world@example.com')).toBeTruthy(); 17 | }); 18 | 19 | test('should be a valid https url with username and password', () => { 20 | expect(Utils.isValidUrl('https://hello:world@example.com')).toBeTruthy(); 21 | }); 22 | 23 | test('should throw error for invalid url', () => { 24 | expect(() => { 25 | Utils.isValidUrl('htt//example.com'); 26 | }).toThrow('Invalid URL'); 27 | }); 28 | 29 | test('should throw error for unsupported protocol', () => { 30 | expect(() => { 31 | Utils.isValidUrl('abc://example.com'); 32 | }).toThrow(ProtocolNotSupported); 33 | }); 34 | }); 35 | 36 | describe('#isValidPlaylist', () => { 37 | test('should be able to detect valid playlist content', () => { 38 | const variantPlaylistContent = `#EXTM3U 39 | #EXT-X-ENDLIST`; 40 | expect(Utils.isValidPlaylist(variantPlaylistContent)).toBeTruthy(); 41 | }); 42 | 43 | test('should be able to detect invalid playlist content', () => { 44 | const variantInvalidPlaylistContent = `#EXT 45 | #EXT-X-ENDLIST`; 46 | expect(Utils.isValidPlaylist(variantInvalidPlaylistContent)).toBeFalsy(); 47 | }); 48 | }); 49 | 50 | describe('#stripFirstSlash', () => { 51 | test('should remove first slash from aboslute file path', () => { 52 | expect(Utils.stripFirstSlash('/some/path/to/playlist.m3u8')).toStrictEqual('some/path/to/playlist.m3u8'); 53 | }); 54 | }); 55 | 56 | describe('#parseUrl', () => { 57 | const aUrl = Utils.parseUrl('http://example.com') || {}; 58 | test('should return an instance of URL', () => { 59 | expect(aUrl).toBeInstanceOf(URL); 60 | }); 61 | test('should parse a valid url', () => { 62 | const { protocol, hostname } = aUrl; 63 | expect(protocol).toStrictEqual('http:'); 64 | expect(hostname).toStrictEqual('example.com'); 65 | }); 66 | 67 | test('should throw error for invalid url', () => { 68 | expect(() => { 69 | Utils.parseUrl('htt//example.com'); 70 | }).toThrow('Invalid URL'); 71 | }); 72 | }); 73 | 74 | describe('#omit', () => { 75 | const subject = { a: 'a', b: 'b', c: 'c', d: 'd' }; 76 | test('should return a trimmed down object when remove array provided', () => { 77 | expect(Utils.omit(subject, 'b', 'd')).toMatchObject({ a: 'a', c: 'c' }); 78 | expect(Utils.omit(subject, ['b', 'd'])).toMatchObject({ a: 'a', c: 'c' }); 79 | expect(Utils.omit(subject, 'b', ['d'])).toMatchObject({ a: 'a', c: 'c' }); 80 | expect(Utils.omit(subject, ['b', ['d']])).toMatchObject({ a: 'a', c: 'c' }); 81 | }); 82 | 83 | test('should return same object when no omit array provided', () => { 84 | expect(Utils.omit(subject)).toMatchObject(subject); 85 | }); 86 | }); 87 | 88 | describe('isNotFunction', () => { 89 | test('should return falsy if function provided', () => { 90 | const subject = () => {}; 91 | expect(Utils.isNotFunction(subject)).toBeFalsy(); 92 | }); 93 | 94 | test('to be truthy if fuction not provided', () => { 95 | const subject = 'NotAFunctionButString'; 96 | expect(Utils.isNotFunction(subject)).toBeTruthy(); 97 | }); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | [![HLSDownloader](./assets/logo.png)](https://nurrony.github.io/hlsdownloader/)
4 | 5 |
6 | 7 |

8 | Downloads HLS Playlist file and TS chunks. You can use it for content pre-fetching from CDN to Edge Server for your end viewers. 9 |

10 | 11 |

12 | NPMDocumentationGitHub 13 |

14 | 15 |
16 | 17 | [![Version](https://img.shields.io/npm/v/hlsdownloader.svg?style=flat-square)](https://www.npmjs.com/package/hlsdownloader) 18 | [![Node](https://img.shields.io/badge/node-%3E%3D18-blue.svg?style=flat-square)](https://www.npmjs.com/package/hlsdownloader) 19 | [![CI](https://github.com/nurrony/hlsdownloader/actions/workflows/test.yaml/badge.svg?style=flat-square)](https://github.com/nurrony/hlsdownloader/actions/workflows/test.yaml) 20 | [![Coverage Status](https://coveralls.io/repos/github/nurrony/hlsdownloader/badge.svg?branch=main)](https://coveralls.io/github/nurrony/hlsdownloader?branch=main) 21 | [![Documentation](https://img.shields.io/badge/documentation-yes-brightgreen.svg?style=flat-square)](https://nurrony.github.io/hlsdownloader) 22 | [![Maintenance](https://img.shields.io/badge/Maintained%3F-yes-green.svg?style=flat-square) ](https://github.com/nurrony/hlsdownloader/graphs/commit-activity) 23 | [![License: MIT](https://img.shields.io/github/license/nurrony/hlsdownloader?style=flat-square) ](https://github.com/nurrony/hlsdownloader/blob/main/LICENSE) 24 | [![Semver: Badge](https://img.shields.io/badge/%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079?style=flat-square) ](https://npmjs.com/package/hlsdownloader) 25 | [![Downloads: HLSDownloader](https://img.shields.io/npm/dm/hlsdownloader.svg?style=flat-square) ](https://npm-stat.com/charts.html?package=hlsdownloader) 26 | [![Min Bundle Size: HLSDownloader](https://img.shields.io/bundlephobia/minzip/hlsdownloader?style=flat-square) ](https://bundlephobia.com/package/hlsdownloader@latest) 27 | [![Known Vulnerabilities](https://snyk.io/test/github/nurrony/hlsdownloader/badge.svg)](https://snyk.io/test/github/nurrony/hlsdownloader) 28 |

29 | 30 |
31 | 32 | > ⚠️ 33 | > This package is native [ESM](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules) and no longer provides a CommonJS export. If your project uses CommonJS, you will have to [convert to ESM](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c). Please don't open issues for questions regarding CommonJS / ESM. 34 | 35 | > ⚠️ 36 | > HLSDownloader `v2.x.x` is no longer maintained and we will not accept any backport requests. 37 | 38 | ## Features 39 | 40 | - Retryable 41 | - Promise Based 42 | - Support for HTTP/2 43 | - Overwrite protection 44 | - Support for custom HTTP Headers 45 | - Support for custom HTTP Client 46 | - Bring your own progress bar during download 47 | - Concurrent download segments with multiple http connections 48 | 49 | ## Prerequisites 50 | 51 | - node >=20.x.x 52 | 53 | ## Installation 54 | 55 | It is pretty straight forward 56 | 57 | ```sh 58 | # using npm 59 | npm install --save hlsdownloader 60 | # or with yarn 61 | yarn add hlsdownloader 62 | # or pnpm 63 | pnpm install hlsdownloader 64 | ``` 65 | 66 | ## How to use 67 | 68 | `destination` field is optional. If `destination` is not provided it just fetches the content from origin. 69 | It can also be useful if you want to do content pre-fetching from CDN for your end viewers. If any `TS` or `m3u8` 70 | variant download is failed it continues downloading others and reports after finishing. 71 | 72 | It's simple as below with. 73 | 74 | ```js 75 | import HLSDownloader from 'hlsdownloader'; 76 | 77 | const options = { 78 | playlistURL: 'http://example.com/path/to/your/playlist.m3u8', // change it 79 | destination: '/tmp', // (optional: default '') 80 | concurrency: 10, // (optional: default = 1), 81 | overwrite: true, // (optional: default = false) 82 | // (optional: default = null) 83 | onData: function (data) { 84 | console.log(data); // {url: "", totalItems: "", path: ""} 85 | }, 86 | // (optional: default = null) 87 | onError: function (error) { 88 | console.log(error); // { url: "", name: "", message: "human readable message of error" } 89 | }, 90 | }; 91 | const downloader = new HLSDownloader(options); 92 | downloader.startDownload().then(response => console.log(response)); 93 | ``` 94 | 95 | > ℹ️ Check [example.js](example.js) for working example 96 | 97 | ```js 98 | // on success 99 | { 100 | total: , 101 | playlistURL: 'your playlist url' 102 | message: 'Downloaded successfully', 103 | } 104 | 105 | // on partial download 106 | { 107 | total: , 108 | playlistURL: 'your playlist url', 109 | message: 'Download done with some errors', 110 | errors: [ 111 | { 112 | name: 'InvalidPlaylist', 113 | message: 'Playlist parsing is not successful' 114 | url: 'https://cnd.hls-server.test/playlist.m3u8' 115 | } 116 | ] // items url that is skipped or could not downloaded for error 117 | } 118 | ``` 119 | 120 | ## Advance Usage 121 | 122 | HLSDownloader supports all [Ky API](https://github.com/sindresorhus/ky?tab=readme-ov-file#api) except these options given below 123 | 124 | - uri 125 | - url 126 | - json 127 | - form 128 | - body 129 | - method 130 | - setHost 131 | - isStream 132 | - parseJson 133 | - prefixUrl 134 | - cookieJar 135 | - playlistURL 136 | - concurrency 137 | - allowGetBody 138 | - stringifyJson 139 | - methodRewriting 140 | 141 | It also disable retry failed request that you can easily override 142 | 143 | ## Running Tests 144 | 145 | ```sh 146 | npm test 147 | ``` 148 | 149 | To run it on watch mode 150 | 151 | ```sh 152 | npm run test:watch 153 | ``` 154 | 155 | ## Generate Documentations 156 | 157 | ```sh 158 | npm docs:gen 159 | ``` 160 | 161 | ## Authors 162 | 163 | 👤 **Nur Rony** 164 | 165 | - Website: [nurrony.github.io](https://nurrony.github.io) 166 | - Twitter: [@nmrony](https://twitter.com/nmrony) 167 | - Github: [@nurrony](https://github.com/nurrony) 168 | - LinkedIn: [@nmrony](https://linkedin.com/in/nmrony) 169 | 170 | ## Contributing 171 | 172 | Contributions, issues and feature requests are welcome!
Feel free to check [issues page](https://github.com/nurrony/hlsdownloader/issues). You can also take a look at the [contributing guide](https://github.com/nurrony/hlsdownloader/blob/main/CONTRIBUTING.md). 173 | 174 | ## Show your support 175 | 176 | Give a ⭐️ if this project helped you!. I will be grateful if you all help me to improve this package by giving your suggestions, feature request and pull requests. I am all ears!! 177 | 178 | ## Special Thanks to 179 | 180 | - [Ky Team](https://www.npmjs.com/package/ky) 181 | 182 | ## License 183 | 184 | Copyright © 2025 [Nur Rony](https://github.com/nurrony).
185 | This project is [MIT](https://github.com/nurrony/hlsdownloader/blob/main/LICENSE) licensed. 186 | -------------------------------------------------------------------------------- /src/Downloader.js: -------------------------------------------------------------------------------- 1 | import { createWriteStream } from 'fs'; 2 | import { access, constants, mkdir, unlink } from 'fs/promises'; 3 | import ky from 'ky'; 4 | import pLimit from 'p-limit'; 5 | import { dirname, join } from 'path'; 6 | import { Readable } from 'stream'; 7 | import { URL } from 'url'; 8 | import { InvalidPlayList } from './exceptions'; 9 | import Utils from './utils'; 10 | 11 | /** 12 | * HLS Playlist file extension 13 | * @constant 14 | * @type {string} 15 | * @memberof module:HLSDownloader 16 | */ 17 | const HLS_PLAYLIST_EXT = '.m3u8'; 18 | 19 | /** 20 | * @class 21 | * @memberof module:HLSDownloader 22 | * @author Nur Rony 23 | * @classdesc Main donwloader class of HLSDownloader Package 24 | */ 25 | class Downloader { 26 | /** @lends Downloader.prototype */ 27 | 28 | /** 29 | * @static 30 | * @type {object} 31 | * @description Default Ky options values set by HLSDownloader 32 | * @default 33 | *
 34 |    * {
 35 |    *   retry: { limit: 0 }
 36 |    * }
 37 |    * 
38 | */ 39 | static defaultKyOptions = { retry: { limit: 0 } }; 40 | 41 | /** 42 | * @type {object} 43 | * @default 1 44 | * @description concurrency controller 45 | */ 46 | pool = pLimit(1); 47 | 48 | /** 49 | * @type {boolean} 50 | * @default false 51 | * @description concurrency controller 52 | */ 53 | overwrite = false; 54 | 55 | /** 56 | * @type {string[]} 57 | * @default 58 | *
 59 |    * [
 60 |    *  'uri',
 61 |    *  'url',
 62 |    *  'json',
 63 |    *  'form',
 64 |    *  'body',
 65 |    *  'method',
 66 |    *  'setHost',
 67 |    *  'isStream',
 68 |    *  'parseJson',
 69 |    *  'prefixUrl',
 70 |    *  'cookieJar',
 71 |    *  'playlistURL',
 72 |    *  'concurrency',
 73 |    *  'allowGetBody',
 74 |    *  'stringifyJson',
 75 |    *  'methodRewriting'
 76 |    * ]
 77 |    * 
78 | */ 79 | static unSupportedOptions = [ 80 | 'uri', 81 | 'url', 82 | 'json', 83 | 'form', 84 | 'body', 85 | 'method', 86 | 'setHost', 87 | 'isStream', 88 | 'parseJson', 89 | 'prefixUrl', 90 | 'cookieJar', 91 | 'playlistURL', 92 | 'concurrency', 93 | 'allowGetBody', 94 | 'stringifyJson', 95 | 'methodRewriting', 96 | ]; 97 | 98 | /** 99 | * @type {string[]} 100 | * @description items that are downloaded successfully 101 | */ 102 | items = []; 103 | 104 | /** 105 | * @type {Array<{url: string, name: string, message: string}>} 106 | * @description items that are not downloaded successfully 107 | */ 108 | errors = []; 109 | 110 | /** 111 | * @type {number=} 112 | * @default 1 113 | * @description Concurrency limit to download items 114 | */ 115 | concurrency = 1; 116 | 117 | /** 118 | * @type {object=} 119 | * @default
{}
120 | * @description Extra options to pass into Ky 121 | */ 122 | kyOptions = {}; 123 | 124 | /** 125 | * @default '' 126 | * @type {string} 127 | * @description Playlist URL to download 128 | */ 129 | playlistURL = ''; 130 | 131 | /** 132 | * @default '' 133 | * @type {string} 134 | * @description Absolute path to download the TS files with corresponding playlist file 135 | */ 136 | destination = ''; 137 | 138 | /** 139 | * @default null 140 | * @type {Function | null} 141 | * @description Function to track downloaded data 142 | */ 143 | onData = null; 144 | 145 | /** 146 | * @default null 147 | * @type {Function | null} 148 | * @description Function to track error 149 | */ 150 | onError = null; 151 | 152 | /** 153 | * @constructor 154 | * @throws TypeError 155 | * @param {object} downloderOptions - Options to build downloader 156 | * @param {string} downloderOptions.playlistURL - Playlist URL to download 157 | * @param {number} [downloderOptions.concurrency = 1] - concurrency limit to download playlist chunk 158 | * @param {string} [downloderOptions.destination = ''] - Absolute path to download 159 | * @param {object | Function | null} [downloderOptions.onData = null] - onData hook 160 | * @param {object | Function | null} [downloderOptions.onError = null] - onError hook 161 | * @param {boolean} [downloderOptions.overwrite = false] - Overwrite files toggler 162 | * @param {object} [downloderOptions.options = {}] - Options to override from Ky 163 | * @throws ProtocolNotSupported 164 | */ 165 | constructor( 166 | { playlistURL, destination, concurrency = 1, overwrite = false, onData = null, onError = null, ...options } = { 167 | concurrency: 1, 168 | destination: '', 169 | playlistURL: '', 170 | onData: null, 171 | onError: null, 172 | overwrite: false, 173 | options: {}, 174 | } 175 | ) { 176 | try { 177 | this.items = [playlistURL]; 178 | this.playlistURL = playlistURL; 179 | this.concurrency = concurrency; 180 | this.overwrite = overwrite ?? false; 181 | this.destination = destination ?? ''; 182 | this.pool = pLimit(concurrency ?? 1); 183 | this.kyOptions = this.mergeOptions(options); 184 | // @ts-ignore 185 | this.onData = onData; 186 | // @ts-ignore 187 | this.onError = onError; 188 | 189 | // method binding 190 | this.fetchItems = this.fetchItems.bind(this); 191 | this.downloadItem = this.downloadItem.bind(this); 192 | this.mergeOptions = this.mergeOptions.bind(this); 193 | this.fetchPlaylist = this.fetchPlaylist.bind(this); 194 | this.startDownload = this.startDownload.bind(this); 195 | this.downloadItems = this.downloadItems.bind(this); 196 | this.shouldOverwrite = this.shouldOverwrite.bind(this); 197 | this.createDirectory = this.createDirectory.bind(this); 198 | this.parsePlaylist = this.parsePlaylist.bind(this); 199 | this.processPlaylistItems = this.processPlaylistItems.bind(this); 200 | this.formatPlaylistContent = this.formatPlaylistContent.bind(this); 201 | 202 | Utils.isValidUrl(playlistURL); 203 | 204 | if (this.onData !== null && Utils.isNotFunction(this.onData)) { 205 | throw TypeError('The `onData` must be a function'); 206 | } 207 | 208 | if (this.onError !== null && Utils.isNotFunction(this.onError)) { 209 | throw TypeError('The `onError` must be a function'); 210 | } 211 | } catch (error) { 212 | throw error; 213 | } 214 | } 215 | 216 | /** 217 | * @method 218 | * @memberof class:Downloader 219 | * @description Start the downloading process 220 | */ 221 | async startDownload() { 222 | const { url, body: playlistContent } = await this.fetchPlaylist(this.playlistURL); 223 | if (this.errors.length > 0) { 224 | return { 225 | errors: this.errors, 226 | message: 'Unsuccessful download', 227 | }; 228 | } 229 | 230 | // @ts-ignore 231 | let urls = this.parsePlaylist(url, playlistContent); 232 | this.items = [...this.items, ...urls]; 233 | const playlists = urls.filter(url => url.toLowerCase().endsWith(HLS_PLAYLIST_EXT)); 234 | const playlistContentPromiseResults = await Promise.allSettled(playlists.map(this.fetchPlaylist)); 235 | const playlistContents = this.formatPlaylistContent(playlistContentPromiseResults); 236 | urls = playlistContents.map(content => this.parsePlaylist(content?.url, content?.body)).flat(); 237 | this.items = [...this.items, ...urls]; 238 | 239 | await this.processPlaylistItems(); 240 | 241 | if (this.errors.length > 0) { 242 | return { 243 | errors: this.errors, 244 | total: this.items.length, 245 | message: 'Download ended with some errors', 246 | }; 247 | } 248 | 249 | return { 250 | total: this.items.length, 251 | playlistURL: this.playlistURL, 252 | message: 'Downloaded successfully', 253 | }; 254 | } 255 | 256 | /** 257 | * @returns {object} 258 | * @param {object} options 259 | * @description merge options 260 | */ 261 | mergeOptions(options) { 262 | return Object.assign({}, Downloader.defaultKyOptions, Utils.omit(options, ...Downloader.unSupportedOptions)); 263 | } 264 | 265 | /** 266 | * @method 267 | * @param {string} playlistContent 268 | * @returns string[] Array of url 269 | * @description Parse playlist content and index the TS chunk to download. 270 | */ 271 | // @ts-ignore 272 | parsePlaylist(playlistURL, playlistContent) { 273 | return playlistContent 274 | .replace(/^#[\s\S].*/gim, '') 275 | .split(/\r?\n/) 276 | .reduce((result, item) => { 277 | if (item !== '') { 278 | const url = new URL(item, playlistURL).href; 279 | //@ts-ignore 280 | result.push(url); 281 | } 282 | return result; 283 | }, []); 284 | } 285 | 286 | /** 287 | * @async 288 | * @method 289 | * @returns {Promise<{url: any, body: any}>} 290 | * @description fetch playlist content 291 | */ 292 | // @ts-ignore 293 | async fetchPlaylist(url) { 294 | try { 295 | const body = await ky.get(url, { ...this.kyOptions }).text(); 296 | if (!Utils.isValidPlaylist(body)) { 297 | const { name, message } = new InvalidPlayList('Invalid playlist'); 298 | this.errors.push({ url, name, message }); 299 | return { url: '', body: '' }; 300 | } 301 | return { url, body }; 302 | // @ts-ignore 303 | } catch ({ name, message }) { 304 | this.errors.push({ url, name, message }); 305 | if (this.onError) { 306 | this.onError({ name, message, url }); 307 | } 308 | return { url: '', body: '' }; 309 | } 310 | } 311 | 312 | /** 313 | * @method 314 | * @description filter playlist contents 315 | * @param {object[]} playlistContentResults list of fetched playlist content 316 | * @returns {Array<{url: string, body: string}>} list of object containing url and its content 317 | */ 318 | formatPlaylistContent(playlistContentResults) { 319 | // @ts-ignore 320 | return playlistContentResults.reduce((contents, { status, value }) => { 321 | if (status.toLowerCase() === 'fulfilled' && !!value) { 322 | // @ts-ignore 323 | contents.push(value); 324 | } 325 | return contents; 326 | }, []); 327 | } 328 | 329 | /** 330 | * @async 331 | * @method 332 | * @returns {Promise} 333 | * @description Process playlist items 334 | */ 335 | async processPlaylistItems() { 336 | return (this.destination && this.downloadItems()) || this.fetchItems(); 337 | } 338 | 339 | /** 340 | * @async 341 | * @method 342 | * @description Download each iteam 343 | * @param {string} item - item to download 344 | * @returns {Promise} 345 | */ 346 | async downloadItem(item) { 347 | try { 348 | const response = await ky.get(item, { ...this.kyOptions }); 349 | const filePath = await this.createDirectory(item); 350 | // @ts-ignore 351 | const readStream = Readable.fromWeb(response.body); 352 | return new Promise((resolve, reject) => { 353 | const writeStream = createWriteStream(filePath); 354 | readStream.pipe(writeStream); 355 | 356 | readStream.on('error', error => { 357 | readStream.destroy(); 358 | writeStream.destroy(); 359 | unlink(filePath); 360 | 361 | if (this.onError) { 362 | this.onError({ 363 | url: item, 364 | name: error.name, 365 | message: error.message, 366 | }); 367 | } 368 | 369 | reject(error); 370 | }); 371 | 372 | writeStream.on('finish', () => { 373 | writeStream.close(); 374 | if (this.onData) { 375 | this.onData({ url: item, totalItems: this.items.length, path: filePath }); 376 | } 377 | resolve('success'); 378 | }); 379 | 380 | writeStream.on('error', error => { 381 | writeStream.destroy(); 382 | readStream.destroy(); 383 | if (this.onError) { 384 | this.onError({ 385 | url: item, 386 | name: error.name, 387 | message: error.message, 388 | }); 389 | } 390 | reject(error); 391 | }); 392 | }); 393 | // @ts-ignore 394 | } catch ({ name, message }) { 395 | this.errors.push({ name, message, url: item }); 396 | if (this.onError) { 397 | this.onError({ name, message, url: item }); 398 | } 399 | } 400 | } 401 | 402 | async downloadItems() { 403 | try { 404 | if (!(await this.shouldOverwrite(this.playlistURL))) { 405 | const error = new Error('directory already exists'); 406 | error.name = 'EEXIST'; 407 | throw error; 408 | } 409 | await this.createDirectory(this.playlistURL); 410 | // @ts-ignore 411 | const downloaderPromises = this.items.map(url => this.pool(this.downloadItem, url)); 412 | return Promise.allSettled(downloaderPromises); 413 | } catch (error) { 414 | // @ts-ignore 415 | this.errors.push({ url: this.playlistURL, name: error.name, message: error.message }); 416 | if (this.onError) { 417 | // @ts-ignore 418 | this.onError({ url: this.playlistURL, name: error.name, message: error.message }); 419 | } 420 | } 421 | } 422 | 423 | /** 424 | * @async 425 | * @method 426 | * @description Fetch playlist items 427 | * @returns {Promise} 428 | */ 429 | async fetchItems() { 430 | return Promise.allSettled( 431 | this.items.map(item => 432 | // @ts-ignore 433 | this.pool(async () => { 434 | try { 435 | const item$ = await ky.get(item, { ...this.kyOptions }); 436 | if (this.onData) { 437 | this.onData({ url: item, totalItems: this.items.length, path: null }); 438 | } 439 | return item$; 440 | // @ts-ignore 441 | } catch ({ name, message }) { 442 | this.errors.push({ url: item, name, message }); 443 | if (this.onError) { 444 | this.onError({ url: item, name, message }); 445 | } 446 | } 447 | }) 448 | ) 449 | ); 450 | } 451 | 452 | /** 453 | * @description create directory to download 454 | * @returns {Promise} destination path 455 | * @param {string} url url to construct the path from 456 | */ 457 | async createDirectory(url) { 458 | // @ts-ignore 459 | const { pathname } = Utils.parseUrl(url); 460 | const destDirectory = join(this.destination, dirname(pathname)); 461 | await mkdir(destDirectory, { recursive: true }); 462 | return join(this.destination, Utils.stripFirstSlash(pathname)); 463 | } 464 | 465 | /** 466 | * @method 467 | * @param {string} url - url to build path from 468 | * @description Checks for overwrite flag 469 | * @returns {Promise} 470 | */ 471 | async shouldOverwrite(url) { 472 | try { 473 | // @ts-ignore 474 | const { pathname } = Utils.parseUrl(url); 475 | const destDirectory = join(this.destination, dirname(pathname)); 476 | await access(destDirectory, constants.F_OK); 477 | return this.overwrite; 478 | } catch (error) { 479 | // @ts-ignore 480 | if (error.code === 'ENOENT') return true; 481 | throw error; 482 | } 483 | } 484 | } 485 | 486 | /** 487 | * @author Nur Rony 488 | * @classdesc Downloads or fetch HLS Playlist and its items 489 | */ 490 | export default Downloader; 491 | -------------------------------------------------------------------------------- /tests/downloader.test.js: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals'; 2 | import { rimraf } from 'rimraf'; 3 | import Utils from '../src/utils'; 4 | import HLSDownloader from './../src'; 5 | 6 | const fail = (reason = 'fail was called in a test.') => { 7 | throw new Error(reason); 8 | }; 9 | 10 | describe('Dowloader', () => { 11 | let downloader; 12 | let fetchSpy; 13 | let isValidPlaylistSpy; 14 | 15 | const options = { 16 | playlistURL: 'http://nmrony.local/hls/example.m3u8', 17 | url: 'http://nmrony.local/hls/example.m3u8', 18 | method: 'POST', 19 | headers: { 20 | Authorization: 'Bearer secret-token', 21 | 'User-Agent': 'My little demo app', 22 | }, 23 | json: true, 24 | form: true, 25 | body: true, 26 | setHost: true, 27 | isStream: true, 28 | parseJson: true, 29 | prefixUrl: true, 30 | cookieJar: true, 31 | allowGetBody: true, 32 | stringifyJson: true, 33 | methodRewriting: true, 34 | }; 35 | 36 | const validPlaylistContent = `#EXTM3U 37 | #EXT-X-TARGETDURATION:10 38 | #EXT-X-VERSION:3 39 | #EXT-X-MEDIA-SEQUENCE:0 40 | #EXT-X-PLAYLIST-TYPE:VOD 41 | #EXTINF:9.00900, 42 | pure-relative.ts 43 | #EXTINF:9.00900, 44 | /other/root-relative.ts 45 | #EXTINF:9.00900, 46 | ../third/relative.ts 47 | #EXTINF:9.00900, 48 | http://www.example.com/other-host.ts 49 | #EXTINF:9.00900, 50 | //www.example.com/things/protocol-relative.ts 51 | #EXT-X-ENDLIST 52 | `; 53 | 54 | const invalidPlaylistContent = ` 55 | #EXT-X-TARGETDURATION:10 56 | #EXT-X-VERSION:3 57 | #EXT-X-MEDIA-SEQUENCE:0 58 | #EXT-X-PLAYLIST-TYPE:VOD 59 | #EXTINF:9.00900, 60 | pure-relative.ts 61 | #EXT-X-ENDLIST 62 | `; 63 | 64 | beforeAll(() => { 65 | isValidPlaylistSpy = jest.spyOn(Utils, 'isValidPlaylist'); 66 | fetchSpy = jest.spyOn(global, 'fetch'); 67 | jest.resetModules(); 68 | }); 69 | 70 | beforeEach(() => { 71 | downloader = new HLSDownloader({ concurrency: 5, playlistURL: 'http://nmrony.local/hls/example.m3u8', options }); 72 | }); 73 | 74 | afterAll(() => { 75 | jest.clearAllMocks(); 76 | }); 77 | describe('#constructor', () => { 78 | it('should throw an error for empty call', () => { 79 | expect(() => { 80 | new HLSDownloader(); 81 | }).toThrow('Invalid URL'); 82 | }); 83 | 84 | it('should have all properties with default values when provided playlistURL only', () => { 85 | const downloader = new HLSDownloader({ playlistURL: 'http://nmrony.local/hls/example.m3u8' }); 86 | expect(downloader).not.toBeNull(); 87 | expect(downloader).toBeInstanceOf(HLSDownloader); 88 | expect(downloader.kyOptions).not.toBeUndefined(); 89 | expect(downloader).toHaveProperty('errors', []); 90 | expect(downloader).toHaveProperty('concurrency', 1); 91 | expect(downloader).toHaveProperty('items', ['http://nmrony.local/hls/example.m3u8']); 92 | expect(downloader).toHaveProperty('playlistURL', 'http://nmrony.local/hls/example.m3u8'); 93 | }); 94 | 95 | it('should not contain unsupported ky options when options are provided', () => { 96 | const downloader = new HLSDownloader({ concurrency: 5, playlistURL: 'http://nmrony.local/hls/example.m3u8' }); 97 | expect(downloader.concurrency).not.toBeNull(); 98 | expect(downloader).toHaveProperty('concurrency', 5); 99 | }); 100 | 101 | it('should not contain unsupported ky options when options are provided', () => { 102 | const downloader = new HLSDownloader({ options, playlistURL: 'http://nmrony.local/hls/example.m3u8' }); 103 | expect(downloader).not.toBeNull(); 104 | expect(downloader).toBeInstanceOf(HLSDownloader); 105 | expect(downloader.kyOptions).toEqual(expect.not.arrayContaining(HLSDownloader.unSupportedOptions)); 106 | }); 107 | 108 | it('destination key should be set properly if provided', () => { 109 | const downloader = new HLSDownloader({ 110 | options, 111 | destination: '/tmp', 112 | playlistURL: 'http://nmrony.local/hls/example.m3u8', 113 | }); 114 | expect(downloader.destination).not.toBeNull(); 115 | expect(downloader.destination).toEqual('/tmp'); 116 | expect(downloader.destination).not.toStrictEqual(''); 117 | }); 118 | 119 | it('should not contain request url key in options', () => { 120 | const downloader = new HLSDownloader({ options, playlistURL: 'http://nmrony.local/hls/example.m3u8' }); 121 | expect(downloader.kyOptions).not.toHaveProperty('url'); 122 | }); 123 | 124 | it('should not contain request method key in options', () => { 125 | const downloader = new HLSDownloader({ options, playlistURL: 'http://nmrony.local/hls/example.m3u8' }); 126 | expect(downloader.kyOptions).not.toHaveProperty('method'); 127 | }); 128 | 129 | it('should contains default options', () => { 130 | const downloader = new HLSDownloader({ options, playlistURL: 'http://nmrony.local/hls/example.m3u8' }); 131 | expect(downloader.kyOptions).toMatchObject(HLSDownloader.defaultKyOptions); 132 | }); 133 | 134 | it('should override default options when provided', () => { 135 | const downloader = new HLSDownloader({ 136 | ...options, 137 | retry: { limit: 10 }, 138 | timeout: { request: 1000 }, 139 | playlistURL: 'http://nmrony.local/hls/example.m3u8', 140 | }); 141 | expect(downloader.kyOptions).toEqual(expect.not.arrayContaining(HLSDownloader.unSupportedOptions)); 142 | expect(downloader.kyOptions).toMatchObject({ timeout: { request: 1000 }, retry: { limit: 10 } }); 143 | }); 144 | 145 | it('should set onData hook to null if not provided', () => { 146 | const downloaderParams = { ...options, playlistURL: 'http://nmrony.local/hls/example.m3u8' }; 147 | const downloader = new HLSDownloader(downloaderParams); 148 | expect(downloader.onData).toBeNull(); 149 | }); 150 | 151 | it('should configure onData hook if provided', () => { 152 | const downloaderParams = { ...options, playlistURL: 'http://nmrony.local/hls/example.m3u8', onData: () => {} }; 153 | const downloader = new HLSDownloader(downloaderParams); 154 | expect(downloader.onData).not.toBeNull(); 155 | }); 156 | 157 | it('should throw error onData hook is not function', () => { 158 | const downloaderParams = { 159 | ...options, 160 | playlistURL: 'http://nmrony.local/hls/example.m3u8', 161 | onData: 'NotAFunction', 162 | }; 163 | 164 | expect(() => { 165 | const downloader = new HLSDownloader(downloaderParams); 166 | }).toThrow('The `onData` must be a function'); 167 | }); 168 | 169 | it('should set onError hook to null if not provided', () => { 170 | const downloaderParams = { ...options, playlistURL: 'http://nmrony.local/hls/example.m3u8' }; 171 | const downloader = new HLSDownloader(downloaderParams); 172 | expect(downloader.onError).toBeNull(); 173 | }); 174 | 175 | it('should configure onError hook if provided', () => { 176 | const downloaderParams = { ...options, playlistURL: 'http://nmrony.local/hls/example.m3u8', onError: () => {} }; 177 | const downloader = new HLSDownloader(downloaderParams); 178 | expect(downloader.onError).not.toBeNull(); 179 | }); 180 | 181 | it('should throw error onError hook is not function', () => { 182 | const downloaderParams = { 183 | ...options, 184 | playlistURL: 'http://nmrony.local/hls/example.m3u8', 185 | onError: 'NotAFunction', 186 | }; 187 | 188 | expect(() => { 189 | const downloader = new HLSDownloader(downloaderParams); 190 | }).toThrow('The `onError` must be a function'); 191 | }); 192 | }); 193 | 194 | describe('#mergeOptions', () => { 195 | let downloader = null; 196 | beforeAll(() => { 197 | downloader = new HLSDownloader({ playlistURL: 'http://nmrony.local/hls/example.m3u8', options }); 198 | }); 199 | 200 | it('should return only default options for no override', () => { 201 | expect(downloader.mergeOptions(options)).toMatchObject(HLSDownloader.defaultKyOptions); 202 | }); 203 | 204 | it('should return options with provided override', () => { 205 | const newOptions = Object.assign({}, options, { 206 | retry: { limit: 10 }, 207 | timeout: { request: 1000 }, 208 | }); 209 | expect(downloader.mergeOptions(newOptions)).toMatchObject({ 210 | retry: { limit: 10 }, 211 | timeout: { request: 1000 }, 212 | }); 213 | }); 214 | }); 215 | 216 | describe('#parsePlaylist', () => { 217 | let items = []; 218 | 219 | beforeEach(() => { 220 | items = downloader.parsePlaylist('http://nmrony.local/hls/example.m3u8', validPlaylistContent); 221 | }); 222 | 223 | it('should parse the correct number of items', () => { 224 | expect(items.length).toStrictEqual(6); 225 | }); 226 | 227 | it('should handle urls with no pathing', () => { 228 | expect(items[0]).toStrictEqual('http://nmrony.local/hls/pure-relative.ts'); 229 | }); 230 | 231 | it('should handle urls root relative pathing', () => { 232 | expect(items[1]).toStrictEqual('http://nmrony.local/other/root-relative.ts'); 233 | }); 234 | 235 | it('should handle urls with subdirectory pathing', () => { 236 | expect(items[2]).toStrictEqual('http://nmrony.local/third/relative.ts'); 237 | }); 238 | 239 | it('should handle urls with absolute urls', () => { 240 | expect(items[3]).toStrictEqual('http://www.example.com/other-host.ts'); 241 | }); 242 | 243 | it('should handle protocol relative urls', () => { 244 | expect(items[4]).toStrictEqual('http://www.example.com/things/protocol-relative.ts'); 245 | }); 246 | }); 247 | 248 | describe('#fetchPlaylist', () => { 249 | it('should fetch playlist fine with valid content', async () => { 250 | fetchSpy.mockResolvedValue(Promise.resolve(new Response(validPlaylistContent))); 251 | const { url, body } = await downloader.fetchPlaylist('http://nmrony.local/hls/playlist.m3u8'); 252 | expect(body).toStrictEqual(validPlaylistContent); 253 | expect(Utils.isValidPlaylist).toHaveBeenCalled(); 254 | expect(isValidPlaylistSpy(validPlaylistContent)).toBeTruthy(); 255 | expect(url).toStrictEqual('http://nmrony.local/hls/playlist.m3u8'); 256 | }); 257 | 258 | it('should report error for invalid playlist content', async () => { 259 | fetchSpy.mockResolvedValue(Promise.resolve(new Response(invalidPlaylistContent))); 260 | await downloader.fetchPlaylist('http://nmrony.local/hls/playlist.m3u8'); 261 | expect(isValidPlaylistSpy).toHaveBeenCalled(); 262 | expect(isValidPlaylistSpy).toHaveBeenCalledTimes(1); 263 | expect(downloader.errors.length).toStrictEqual(1); 264 | expect(isValidPlaylistSpy(invalidPlaylistContent)).toBeFalsy(); 265 | }); 266 | 267 | it('should report error for http errors', async () => { 268 | try { 269 | await downloader.fetchPlaylist('http://nmrony.local/hls/playlist.m3u8'); 270 | fail('404'); 271 | } catch (error) { 272 | expect(downloader.errors.length).toStrictEqual(1); 273 | expect(error.message).toStrictEqual('404'); 274 | } 275 | }); 276 | }); 277 | 278 | describe('#formatPlaylistContent', () => { 279 | let result = []; 280 | const fetchedData = [ 281 | { status: 'fulfilled', value: 'http://nmrony.local/hls/1.ts' }, 282 | { status: 'fulfilled', value: 'http://nmrony.local/hls/2.ts' }, 283 | { status: 'rejected', reason: 'http://nmrony.local/hls/3.ts' }, 284 | ]; 285 | beforeEach(() => { 286 | result = downloader.formatPlaylistContent(fetchedData); 287 | }); 288 | 289 | it('should process fulfilled items fine', () => { 290 | expect(result.length).toEqual(2); 291 | }); 292 | }); 293 | 294 | describe('#startDwonload', () => { 295 | let fetchPlaylistSpy = null; 296 | let parsePlaylistSpy = null; 297 | let downloadItemSpy = null; 298 | let downloadItemsSpy = null; 299 | let processPlaylistItemsSpy = null; 300 | const destination = '/tmp/test'; 301 | 302 | beforeEach(() => { 303 | fetchPlaylistSpy = jest.spyOn(downloader, 'fetchPlaylist'); 304 | downloadItemSpy = jest.spyOn(downloader, 'downloadItem'); 305 | downloadItemsSpy = jest.spyOn(downloader, 'downloadItems'); 306 | parsePlaylistSpy = jest.spyOn(downloader, 'parsePlaylist'); 307 | processPlaylistItemsSpy = jest.spyOn(downloader, 'processPlaylistItems'); 308 | }); 309 | 310 | afterEach(() => { 311 | downloadItemSpy.mockReset(); 312 | downloadItemsSpy.mockReset(); 313 | fetchPlaylistSpy.mockReset(); 314 | parsePlaylistSpy.mockReset(); 315 | processPlaylistItemsSpy.mockReset(); 316 | }); 317 | 318 | afterAll(async () => {}); 319 | 320 | it('should return empty error for http or invalid playlist', async () => { 321 | let result = null; 322 | fetchSpy.mockRejectedValueOnce(new Error('404')); 323 | try { 324 | result = await downloader.startDownload(); 325 | fail('Invalid Playlist'); 326 | } catch (error) { 327 | expect(fetchPlaylistSpy).toHaveBeenCalled(); 328 | expect(fetchPlaylistSpy).toHaveBeenCalledTimes(1); 329 | expect(fetchPlaylistSpy).toHaveReturnedTimes(1); 330 | expect(downloader.errors.length).toBeGreaterThan(0); 331 | expect(error.message).toStrictEqual('Invalid Playlist'); 332 | } 333 | }); 334 | 335 | it('should fetch items for valid url', async () => { 336 | fetchSpy.mockResolvedValue(Promise.resolve(new Response(validPlaylistContent))); 337 | await downloader.startDownload(); 338 | expect(fetchPlaylistSpy).toHaveBeenCalled(); 339 | expect(parsePlaylistSpy).toHaveBeenCalled(); 340 | expect(processPlaylistItemsSpy).toHaveBeenCalled(); 341 | expect(fetchPlaylistSpy).toHaveBeenCalledTimes(2); 342 | expect(parsePlaylistSpy).toHaveBeenCalledTimes(2); 343 | expect(downloader.errors.length).toStrictEqual(1); 344 | }); 345 | 346 | it('should report error with invalid content', async () => { 347 | fetchSpy.mockResolvedValue(Promise.resolve(new Response(invalidPlaylistContent))); 348 | await downloader.startDownload(); 349 | expect(fetchPlaylistSpy).toHaveBeenCalled(); 350 | expect(fetchPlaylistSpy).toHaveBeenCalledTimes(1); 351 | expect(downloader.errors.length).toBeGreaterThan(0); 352 | }); 353 | 354 | it('should report error with invalid content', async () => { 355 | fetchSpy.mockResolvedValue(Promise.resolve(new Response(invalidPlaylistContent))); 356 | await downloader.startDownload(); 357 | expect(fetchPlaylistSpy).toHaveBeenCalled(); 358 | expect(fetchPlaylistSpy).toHaveBeenCalledTimes(1); 359 | expect(downloader.errors.length).toBeGreaterThan(0); 360 | }); 361 | 362 | it('should download items for valid url', async () => { 363 | downloader = new HLSDownloader({ 364 | destination, 365 | concurrency: 5, 366 | overwrite: true, 367 | playlistURL: 'http://nmrony.local/hls/example.m3u8', 368 | }); 369 | fetchSpy.mockResolvedValue(Promise.resolve(new Response(validPlaylistContent))); 370 | await downloader.startDownload(); 371 | // expect(fetchPlaylistSpy).toHaveBeenCalled(); 372 | // expect(parsePlaylistSpy).toHaveBeenCalled(); 373 | // expect(processPlaylistItemsSpy).toHaveBeenCalled(); 374 | // expect(fetchPlaylistSpy).toHaveBeenCalledTimes(2); 375 | // expect(parsePlaylistSpy).toHaveBeenCalledTimes(2); 376 | // expect(downloader.errors.length).toStrictEqual(0); 377 | }); 378 | 379 | it('should not download items for valid url', async () => { 380 | downloader = new HLSDownloader({ 381 | destination, 382 | concurrency: 5, 383 | overwrite: false, 384 | playlistURL: 'http://nmrony.local/hls/example.m3u8', 385 | }); 386 | fetchSpy.mockResolvedValue(Promise.resolve(new Response(validPlaylistContent))); 387 | await downloader.startDownload(); 388 | expect(fetchPlaylistSpy).not.toHaveBeenCalled(); 389 | expect(parsePlaylistSpy).not.toHaveBeenCalled(); 390 | expect(processPlaylistItemsSpy).not.toHaveBeenCalled(); 391 | await rimraf(destination); 392 | }); 393 | }); 394 | }); 395 | --------------------------------------------------------------------------------