├── .editorconfig ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmignore ├── .nvmrc ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── index-player-options.html ├── index.html ├── moose_encrypted.webm ├── package-lock.json ├── package.json ├── scripts ├── jsdoc.config.json ├── karma.conf.js └── rollup.config.js ├── src ├── cdm.js ├── consts │ └── errors.js ├── eme.js ├── fairplay.js ├── http-handler.js ├── ms-prefixed.js ├── playready.js ├── plugin.js └── utils.js └── test ├── cdm.test.js ├── eme.test.js ├── fairplay.test.js ├── ms-prefixed.test.js ├── playready-message.js ├── playready.test.js ├── plugin.test.js ├── utils.js └── utils.test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_style = space 8 | indent_size = 2 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | should-skip: 7 | continue-on-error: true 8 | runs-on: ubuntu-latest 9 | # Map a step output to a job output 10 | outputs: 11 | should-skip-job: ${{steps.skip-check.outputs.should_skip}} 12 | steps: 13 | - id: skip-check 14 | uses: fkirc/skip-duplicate-actions@v5.3.0 15 | with: 16 | github_token: ${{github.token}} 17 | 18 | ci: 19 | needs: should-skip 20 | if: ${{needs.should-skip.outputs.should-skip-job != 'true' || github.ref == 'refs/heads/main'}} 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | os: [ubuntu-latest] 25 | env: 26 | BROWSER_STACK_USERNAME: ${{secrets.BROWSER_STACK_USERNAME}} 27 | BROWSER_STACK_ACCESS_KEY: ${{secrets.BROWSER_STACK_ACCESS_KEY}} 28 | runs-on: ${{matrix.os}} 29 | steps: 30 | - name: checkout code 31 | uses: actions/checkout@v3 32 | 33 | - name: Cache dependencies 34 | uses: actions/cache@v3 35 | with: 36 | path: | 37 | ~/.npm 38 | **/node_modules 39 | key: ${{runner.os}}-npm-${{hashFiles('**/package-lock.json')}} 40 | restore-keys: | 41 | ${{runner.os}}-npm- 42 | ${{runner.os}}- 43 | 44 | - name: read node version from .nvmrc 45 | run: echo "NVMRC=$(cat .nvmrc)" >> $GITHUB_OUTPUT 46 | shell: bash 47 | id: nvm 48 | 49 | - name: update apt cache on linux w/o browserstack 50 | run: sudo apt-get update 51 | if: ${{startsWith(matrix.os, 'ubuntu') && !env.BROWSER_STACK_USERNAME}} 52 | 53 | - name: install ffmpeg/pulseaudio for firefox on linux w/o browserstack 54 | run: sudo apt-get install ffmpeg pulseaudio 55 | if: ${{startsWith(matrix.os, 'ubuntu') && !env.BROWSER_STACK_USERNAME}} 56 | 57 | - name: start pulseaudio for firefox on linux w/o browserstack 58 | run: pulseaudio -D 59 | if: ${{startsWith(matrix.os, 'ubuntu') && !env.BROWSER_STACK_USERNAME}} 60 | 61 | - name: setup node 62 | uses: actions/setup-node@v3 63 | with: 64 | node-version: '${{steps.nvm.outputs.NVMRC}}' 65 | 66 | # turn off the default setup-node problem watchers... 67 | - run: echo "::remove-matcher owner=eslint-compact::" 68 | - run: echo "::remove-matcher owner=eslint-stylish::" 69 | - run: echo "::remove-matcher owner=tsc::" 70 | 71 | - name: npm install 72 | run: npm i --prefer-offline --no-audit 73 | 74 | - name: run npm test 75 | uses: coactions/setup-xvfb@v1 76 | with: 77 | run: npm run test 78 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS 2 | Thumbs.db 3 | ehthumbs.db 4 | Desktop.ini 5 | .DS_Store 6 | ._* 7 | 8 | # Editors 9 | *~ 10 | *.swp 11 | *.tmproj 12 | *.tmproject 13 | *.sublime-* 14 | .idea/ 15 | .project/ 16 | .settings/ 17 | .vscode/ 18 | 19 | # Logs 20 | logs 21 | *.log 22 | npm-debug.log* 23 | 24 | # Dependency directories 25 | bower_components/ 26 | node_modules/ 27 | 28 | # Build-related directories 29 | dist/ 30 | docs/api/ 31 | test/dist/ 32 | .eslintcache 33 | .yo-rc.json 34 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Intentionally left blank, so that npm does not ignore anything by default, 2 | # but relies on the package.json "files" array to explicitly define what ends 3 | # up in the package. 4 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/gallium 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | ## [5.5.1](https://github.com/videojs/videojs-contrib-eme/compare/v5.5.0...v5.5.1) (2024-08-27) 3 | 4 | ### Bug Fixes 5 | 6 | * excessive requests when using HDCP fallback with LDL (#225) ([348935e](https://github.com/videojs/videojs-contrib-eme/commit/348935e)), closes [#225](https://github.com/videojs/videojs-contrib-eme/issues/225) 7 | 8 | 9 | # [5.5.0](https://github.com/videojs/videojs-contrib-eme/compare/v5.4.1...v5.5.0) (2024-07-08) 10 | 11 | ### Features 12 | 13 | * add manual setup eme listeners (#226) ([a22099a](https://github.com/videojs/videojs-contrib-eme/commit/a22099a)), closes [#226](https://github.com/videojs/videojs-contrib-eme/issues/226) 14 | 15 | 16 | ## [5.4.1](https://github.com/videojs/videojs-contrib-eme/compare/v5.4.0...v5.4.1) (2024-06-13) 17 | 18 | ### Bug Fixes 19 | 20 | * pass emeError to handleWebKitNeedKeyEvent to avoid TypeError (#222) ([463c839](https://github.com/videojs/videojs-contrib-eme/commit/463c839)), closes [#222](https://github.com/videojs/videojs-contrib-eme/issues/222) 21 | 22 | 23 | # [5.4.0](https://github.com/videojs/videojs-contrib-eme/compare/v5.3.2...v5.4.0) (2024-06-04) 24 | 25 | ### Features 26 | 27 | * add metadata to the license requests (#221) ([7621c86](https://github.com/videojs/videojs-contrib-eme/commit/7621c86)), closes [#221](https://github.com/videojs/videojs-contrib-eme/issues/221) 28 | 29 | 30 | ## [5.3.2](https://github.com/videojs/videojs-contrib-eme/compare/v5.3.1...v5.3.2) (2024-05-17) 31 | 32 | ### Bug Fixes 33 | 34 | * fix cleanup issues (#220) ([6b82ffa](https://github.com/videojs/videojs-contrib-eme/commit/6b82ffa)), closes [#220](https://github.com/videojs/videojs-contrib-eme/issues/220) 35 | 36 | 37 | ## [5.3.1](https://github.com/videojs/videojs-contrib-eme/compare/v5.3.0...v5.3.1) (2024-05-16) 38 | 39 | ### Bug Fixes 40 | 41 | * eventBus error after dispose (#218) ([730bdd9](https://github.com/videojs/videojs-contrib-eme/commit/730bdd9)), closes [#218](https://github.com/videojs/videojs-contrib-eme/issues/218) 42 | 43 | 44 | # [5.3.0](https://github.com/videojs/videojs-contrib-eme/compare/v5.2.1...v5.3.0) (2024-05-09) 45 | 46 | ### Features 47 | 48 | * add legacy fairplay flow cleanup (#219) ([b7ce1e1](https://github.com/videojs/videojs-contrib-eme/commit/b7ce1e1)), closes [#219](https://github.com/videojs/videojs-contrib-eme/issues/219) 49 | 50 | 51 | ## [5.2.1](https://github.com/videojs/videojs-contrib-eme/compare/v5.2.0...v5.2.1) (2024-04-23) 52 | 53 | ### Chores 54 | 55 | * move eme errors (#215) ([1a000bc](https://github.com/videojs/videojs-contrib-eme/commit/1a000bc)), closes [#215](https://github.com/videojs/videojs-contrib-eme/issues/215) 56 | 57 | 58 | # [5.2.0](https://github.com/videojs/videojs-contrib-eme/compare/v5.1.2...v5.2.0) (2024-03-28) 59 | 60 | ### Features 61 | 62 | * eme error interface (#208) ([5820da7](https://github.com/videojs/videojs-contrib-eme/commit/5820da7)), closes [#208](https://github.com/videojs/videojs-contrib-eme/issues/208) 63 | * event payloads (#210) ([8bc0cff](https://github.com/videojs/videojs-contrib-eme/commit/8bc0cff)), closes [#210](https://github.com/videojs/videojs-contrib-eme/issues/210) 64 | 65 | 66 | ## [5.1.2](https://github.com/videojs/videojs-contrib-eme/compare/v5.1.1...v5.1.2) (2024-03-12) 67 | 68 | ### Bug Fixes 69 | 70 | * getOptions after encrypted event (#209) ([6b0b6dd](https://github.com/videojs/videojs-contrib-eme/commit/6b0b6dd)), closes [#209](https://github.com/videojs/videojs-contrib-eme/issues/209) 71 | 72 | 73 | ## [5.1.1](https://github.com/videojs/videojs-contrib-eme/compare/v5.1.0...v5.1.1) (2024-02-28) 74 | 75 | ### Bug Fixes 76 | 77 | * fixes an issue where events were using stale player options (#207) ([92aae13](https://github.com/videojs/videojs-contrib-eme/commit/92aae13)), closes [#207](https://github.com/videojs/videojs-contrib-eme/issues/207) 78 | 79 | 80 | # [5.1.0](https://github.com/videojs/videojs-contrib-eme/compare/v5.0.0...v5.1.0) (2024-02-17) 81 | 82 | ### Features 83 | 84 | * Add CDM detection module (#98) ([33dfe13](https://github.com/videojs/videojs-contrib-eme/commit/33dfe13)), closes [#98](https://github.com/videojs/videojs-contrib-eme/issues/98) 85 | * add request types (#200) ([58109ca](https://github.com/videojs/videojs-contrib-eme/commit/58109ca)), closes [#200](https://github.com/videojs/videojs-contrib-eme/issues/200) 86 | 87 | ### Bug Fixes 88 | 89 | * Close keySession when player is disposed (#176) ([c8ca31a](https://github.com/videojs/videojs-contrib-eme/commit/c8ca31a)), closes [#176](https://github.com/videojs/videojs-contrib-eme/issues/176) 90 | * disable firefox headless in karma (#205) ([a90edcb](https://github.com/videojs/videojs-contrib-eme/commit/a90edcb)), closes [#205](https://github.com/videojs/videojs-contrib-eme/issues/205) 91 | * legacy fairplay (#204) ([ee6e512](https://github.com/videojs/videojs-contrib-eme/commit/ee6e512)), closes [#204](https://github.com/videojs/videojs-contrib-eme/issues/204) 92 | * playready message passthrough (#201) ([78bc2d7](https://github.com/videojs/videojs-contrib-eme/commit/78bc2d7)), closes [#201](https://github.com/videojs/videojs-contrib-eme/issues/201) 93 | 94 | ### Chores 95 | 96 | * Update CI workflow (#188) ([579df4c](https://github.com/videojs/videojs-contrib-eme/commit/579df4c)), closes [#188](https://github.com/videojs/videojs-contrib-eme/issues/188) 97 | 98 | ### Code Refactoring 99 | 100 | * add support for future Video.js 8.x (#172) ([6a19aba](https://github.com/videojs/videojs-contrib-eme/commit/6a19aba)), closes [#172](https://github.com/videojs/videojs-contrib-eme/issues/172) 101 | 102 | 103 | # [5.0.0](https://github.com/videojs/videojs-contrib-eme/compare/v4.0.0...v5.0.0) (2022-08-19) 104 | 105 | ### Features 106 | 107 | * expose plugin version (#163) ([5f9a1ea](https://github.com/videojs/videojs-contrib-eme/commit/5f9a1ea)), closes [#163](https://github.com/videojs/videojs-contrib-eme/issues/163) 108 | 109 | ### Chores 110 | 111 | * **package:** update to Node 16 and run npm audit (#170) ([0f68f21](https://github.com/videojs/videojs-contrib-eme/commit/0f68f21)), closes [#170](https://github.com/videojs/videojs-contrib-eme/issues/170) 112 | * update jsdoc ([01e887f](https://github.com/videojs/videojs-contrib-eme/commit/01e887f)) 113 | * update tooling to remove ie transpiling, update broken tests (#169) ([4b330d5](https://github.com/videojs/videojs-contrib-eme/commit/4b330d5)), closes [#169](https://github.com/videojs/videojs-contrib-eme/issues/169) 114 | 115 | ### Tests 116 | 117 | * cleanup xhr correctly (#150) ([b992167](https://github.com/videojs/videojs-contrib-eme/commit/b992167)), closes [#150](https://github.com/videojs/videojs-contrib-eme/issues/150) 118 | 119 | 120 | ### BREAKING CHANGES 121 | 122 | * Internet Explorer will no longer work. 123 | 124 | 125 | ## [4.0.1](https://github.com/videojs/videojs-contrib-eme/compare/v4.0.0...v4.0.1) (2022-04-22) 126 | 127 | ### Chores 128 | 129 | * update jsdoc ([01e887f](https://github.com/videojs/videojs-contrib-eme/commit/01e887f)) 130 | 131 | ### Tests 132 | 133 | * cleanup xhr correctly (#150) ([b992167](https://github.com/videojs/videojs-contrib-eme/commit/b992167)), closes [#150](https://github.com/videojs/videojs-contrib-eme/issues/150) 134 | 135 | 136 | # [4.0.0](https://github.com/videojs/videojs-contrib-eme/compare/v3.10.1...v4.0.0) (2021-10-20) 137 | 138 | ### Bug Fixes 139 | 140 | * convert initData to a string (#147) ([922e5eb](https://github.com/videojs/videojs-contrib-eme/commit/922e5eb)), closes [#147](https://github.com/videojs/videojs-contrib-eme/issues/147) 141 | 142 | ### Reverts 143 | 144 | * "revert: fix: use in-spec EME for versions of Safari which support it (#142) (#145)" (#146) ([c912bda](https://github.com/videojs/videojs-contrib-eme/commit/c912bda)), closes [#142](https://github.com/videojs/videojs-contrib-eme/issues/142) [#145](https://github.com/videojs/videojs-contrib-eme/issues/145) [#146](https://github.com/videojs/videojs-contrib-eme/issues/146) 145 | 146 | 147 | ### BREAKING CHANGES 148 | 149 | * getContentId will now receive a string representation 150 | of the initData 151 | 152 | 153 | ## [3.10.1](https://github.com/videojs/videojs-contrib-eme/compare/v3.10.0...v3.10.1) (2021-10-19) 154 | 155 | ### Reverts 156 | 157 | * fix: use in-spec EME for versions of Safari which support it (#142) (#145) ([fdb57e3](https://github.com/videojs/videojs-contrib-eme/commit/fdb57e3)), closes [#142](https://github.com/videojs/videojs-contrib-eme/issues/142) [#145](https://github.com/videojs/videojs-contrib-eme/issues/145) 158 | 159 | 160 | # [3.10.0](https://github.com/videojs/videojs-contrib-eme/compare/v3.9.0...v3.10.0) (2021-10-15) 161 | 162 | ### Bug Fixes 163 | 164 | * use in-spec EME for versions of Safari which support it (#142) ([5897655](https://github.com/videojs/videojs-contrib-eme/commit/5897655)), closes [#142](https://github.com/videojs/videojs-contrib-eme/issues/142) [#87](https://github.com/videojs/videojs-contrib-eme/issues/87) 165 | 166 | ### Chores 167 | 168 | * update linter, run --fix on files, and manually lint when needed (#141) ([a794ea9](https://github.com/videojs/videojs-contrib-eme/commit/a794ea9)), closes [#141](https://github.com/videojs/videojs-contrib-eme/issues/141) 169 | 170 | 171 | # [3.9.0](https://github.com/videojs/videojs-contrib-eme/compare/v3.8.1...v3.9.0) (2021-07-27) 172 | 173 | ### Features 174 | 175 | * on license request errors, return response body as cause (#137) ([a9a5b82](https://github.com/videojs/videojs-contrib-eme/commit/a9a5b82)), closes [#137](https://github.com/videojs/videojs-contrib-eme/issues/137) 176 | 177 | 178 | ## [3.8.1](https://github.com/videojs/videojs-contrib-eme/compare/v3.8.0...v3.8.1) (2021-05-19) 179 | 180 | ### Bug Fixes 181 | 182 | * handle initial duplicate webkitneedskey to prevent error dialog (#134) ([5ded675](https://github.com/videojs/videojs-contrib-eme/commit/5ded675)), closes [#134](https://github.com/videojs/videojs-contrib-eme/issues/134) 183 | 184 | ### Chores 185 | 186 | * update docs for robustness and mention widevine warning (#129) ([9c4f577](https://github.com/videojs/videojs-contrib-eme/commit/9c4f577)), closes [#129](https://github.com/videojs/videojs-contrib-eme/issues/129) 187 | 188 | 189 | # [3.8.0](https://github.com/videojs/videojs-contrib-eme/compare/v3.7.1...v3.8.0) (2020-11-18) 190 | 191 | ### Features 192 | 193 | * add keysessioncreated event (#124) ([d114979](https://github.com/videojs/videojs-contrib-eme/commit/d114979)), closes [#124](https://github.com/videojs/videojs-contrib-eme/issues/124) 194 | 195 | 196 | ## [3.7.1](https://github.com/videojs/videojs-contrib-eme/compare/v3.7.0...v3.7.1) (2020-09-15) 197 | 198 | ### Bug Fixes 199 | 200 | * only getLicense on license-request or license-renewal (#116) ([c15d1ca](https://github.com/videojs/videojs-contrib-eme/commit/c15d1ca)), closes [#116](https://github.com/videojs/videojs-contrib-eme/issues/116) 201 | * try to re-request key if the session expired (#120) ([20d6adc](https://github.com/videojs/videojs-contrib-eme/commit/20d6adc)), closes [#120](https://github.com/videojs/videojs-contrib-eme/issues/120) 202 | 203 | 204 | # [3.7.0](https://github.com/videojs/videojs-contrib-eme/compare/v3.6.0...v3.7.0) (2020-04-01) 205 | 206 | ### Features 207 | 208 | * add support for setting persistentState in supportedConfigurations (#102) ([ef9fa23](https://github.com/videojs/videojs-contrib-eme/commit/ef9fa23)), closes [#102](https://github.com/videojs/videojs-contrib-eme/issues/102) 209 | 210 | 211 | # [3.6.0](https://github.com/videojs/videojs-contrib-eme/compare/v3.5.6...v3.6.0) (2020-02-12) 212 | 213 | ### Features 214 | 215 | * support setting robustness and supportedConfigurations (#100) ([502c8ea](https://github.com/videojs/videojs-contrib-eme/commit/502c8ea)), closes [#100](https://github.com/videojs/videojs-contrib-eme/issues/100) 216 | 217 | 218 | ## [3.5.6](https://github.com/videojs/videojs-contrib-eme/compare/v3.5.5...v3.5.6) (2020-02-10) 219 | 220 | ### Bug Fixes 221 | 222 | * save session-specific options in pending session data when waiting on media keys (#96) ([6cdbfa8](https://github.com/videojs/videojs-contrib-eme/commit/6cdbfa8)), closes [#96](https://github.com/videojs/videojs-contrib-eme/issues/96) 223 | 224 | 225 | ## [3.5.5](https://github.com/videojs/videojs-contrib-eme/compare/v3.5.4...v3.5.5) (2020-02-06) 226 | 227 | ### Bug Fixes 228 | 229 | * getLicense should pass an error to callback if XHR returns 400/500 (#99) ([498ebaf](https://github.com/videojs/videojs-contrib-eme/commit/498ebaf)), closes [#99](https://github.com/videojs/videojs-contrib-eme/issues/99) 230 | 231 | 232 | ## [3.5.4](https://github.com/videojs/videojs-contrib-eme/compare/v3.5.3...v3.5.4) (2019-05-08) 233 | 234 | ### Bug Fixes 235 | 236 | * use legacy WebKit API when available to address Safari 12.1 issues ([7a20e5d](https://github.com/videojs/videojs-contrib-eme/commit/7a20e5d)) 237 | * use new method signature for requestPlayreadyLicense from #81 (#85) ([36d5f9c](https://github.com/videojs/videojs-contrib-eme/commit/36d5f9c)), closes [#81](https://github.com/videojs/videojs-contrib-eme/issues/81) [#85](https://github.com/videojs/videojs-contrib-eme/issues/85) 238 | 239 | 240 | ## [3.5.3](https://github.com/videojs/videojs-contrib-eme/compare/v3.5.2...v3.5.3) (2019-05-02) 241 | 242 | ### Bug Fixes 243 | 244 | * Fix support for custom getLicense methods for ms-prefixed PlayReady (#84) ([473f535](https://github.com/videojs/videojs-contrib-eme/commit/473f535)), closes [#84](https://github.com/videojs/videojs-contrib-eme/issues/84) 245 | 246 | 247 | ## [3.5.2](https://github.com/videojs/videojs-contrib-eme/compare/v3.5.1...v3.5.2) (2019-05-01) 248 | 249 | ### Bug Fixes 250 | 251 | * Fix regression in ms-prefixed PlayReady license request callbacks caused by #81 (#83) ([b52ba35](https://github.com/videojs/videojs-contrib-eme/commit/b52ba35)), closes [#81](https://github.com/videojs/videojs-contrib-eme/issues/81) [#83](https://github.com/videojs/videojs-contrib-eme/issues/83) 252 | 253 | 254 | ## [3.5.1](https://github.com/videojs/videojs-contrib-eme/compare/v3.5.0...v3.5.1) (2019-05-01) 255 | 256 | ### Bug Fixes 257 | 258 | * Use correct callback function signature for PlayReady license request callbacks. (#81) ([07a1f25](https://github.com/videojs/videojs-contrib-eme/commit/07a1f25)), closes [#81](https://github.com/videojs/videojs-contrib-eme/issues/81) 259 | 260 | ### Chores 261 | 262 | * **package:** Regenerate package-lock.json (#82) ([beb62af](https://github.com/videojs/videojs-contrib-eme/commit/beb62af)), closes [#82](https://github.com/videojs/videojs-contrib-eme/issues/82) 263 | 264 | 265 | # [3.5.0](https://github.com/videojs/videojs-contrib-eme/compare/v3.4.1...v3.5.0) (2019-03-20) 266 | 267 | ### Features 268 | 269 | * Add support for defining custom headers for default license and certificate requests. (#76) ([7197390](https://github.com/videojs/videojs-contrib-eme/commit/7197390)), closes [#76](https://github.com/videojs/videojs-contrib-eme/issues/76) 270 | * Trigger player errors from EME errors and refactor to use promises internally. ([7cae936](https://github.com/videojs/videojs-contrib-eme/commit/7cae936)) 271 | 272 | ### Chores 273 | 274 | * use npm lts/carbon (#71) ([dc5d8c4](https://github.com/videojs/videojs-contrib-eme/commit/dc5d8c4)), closes [#71](https://github.com/videojs/videojs-contrib-eme/issues/71) 275 | 276 | ### Tests 277 | 278 | * Update dependencies and fix test that fails in Safari. (#77) ([5238d08](https://github.com/videojs/videojs-contrib-eme/commit/5238d08)), closes [#77](https://github.com/videojs/videojs-contrib-eme/issues/77) 279 | 280 | 281 | ## [3.4.1](https://github.com/videojs/videojs-contrib-eme/compare/v3.4.0...v3.4.1) (2018-10-24) 282 | 283 | ### Bug Fixes 284 | 285 | * check for init data (#62) ([d966e5b](https://github.com/videojs/videojs-contrib-eme/commit/d966e5b)), closes [#62](https://github.com/videojs/videojs-contrib-eme/issues/62) 286 | 287 | 288 | # [3.4.0](https://github.com/videojs/videojs-contrib-eme/compare/v3.3.0...v3.4.0) (2018-10-24) 289 | 290 | ### Features 291 | 292 | * added API to set media keys directly (#61) ([57701b9](https://github.com/videojs/videojs-contrib-eme/commit/57701b9)), closes [#61](https://github.com/videojs/videojs-contrib-eme/issues/61) 293 | 294 | ### Bug Fixes 295 | 296 | * pass along preset PlayReady init data (#60) ([746e5ed](https://github.com/videojs/videojs-contrib-eme/commit/746e5ed)), closes [#60](https://github.com/videojs/videojs-contrib-eme/issues/60) 297 | 298 | ### Chores 299 | 300 | * Update using plugin generator v7.2.4 (#52) ([761f547](https://github.com/videojs/videojs-contrib-eme/commit/761f547)), closes [#52](https://github.com/videojs/videojs-contrib-eme/issues/52) 301 | 302 | ### Documentation 303 | 304 | * removing maintainers section (#53) ([12beb15](https://github.com/videojs/videojs-contrib-eme/commit/12beb15)), closes [#53](https://github.com/videojs/videojs-contrib-eme/issues/53) 305 | * update readme to include plugin init (#51) ([050c300](https://github.com/videojs/videojs-contrib-eme/commit/050c300)), closes [#51](https://github.com/videojs/videojs-contrib-eme/issues/51) 306 | * use tech() and not tech_ (#46) ([3b724b6](https://github.com/videojs/videojs-contrib-eme/commit/3b724b6)), closes [#46](https://github.com/videojs/videojs-contrib-eme/issues/46) 307 | 308 | # CHANGELOG 309 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # CONTRIBUTING 2 | 3 | We welcome contributions from everyone! 4 | 5 | ## Getting Started 6 | 7 | Make sure you have Node.js 4.8 or higher and npm installed. 8 | 9 | 1. Fork this repository and clone your fork 10 | 1. Install dependencies: `npm install` 11 | 1. Run a development server: `npm start` 12 | 13 | ### Making Changes 14 | 15 | Refer to the [video.js plugin conventions][conventions] for more detail on best practices and tooling for video.js plugin authorship. 16 | 17 | When you've made your changes, push your commit(s) to your fork and issue a pull request against the original repository. 18 | 19 | ### Running Tests 20 | 21 | Testing is a crucial part of any software project. For all but the most trivial changes (typos, etc) test cases are expected. Tests are run in actual browsers using [Karma][karma]. 22 | 23 | - In all available and supported browsers: `npm test` 24 | - In a specific browser: `npm run test:chrome`, `npm run test:firefox`, etc. 25 | - While development server is running (`npm start`), navigate to [`http://localhost:9999/test/`][local] 26 | 27 | 28 | [karma]: http://karma-runner.github.io/ 29 | [local]: http://localhost:9999/test/ 30 | [conventions]: https://github.com/videojs/generator-videojs-plugin/blob/master/docs/conventions.md 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Brightcove, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # videojs-contrib-eme 2 | 3 | 4 | [![Build Status](https://travis-ci.org/videojs/videojs-contrib-eme.svg?branch=master)](https://travis-ci.org/videojs/videojs-contrib-eme) 5 | [![Greenkeeper badge](https://badges.greenkeeper.io/videojs/videojs-contrib-eme.svg)](https://greenkeeper.io/) 6 | [![Slack Status](http://slack.videojs.com/badge.svg)](http://slack.videojs.com) 7 | 8 | [![NPM](https://nodei.co/npm/videojs-contrib-eme.png?downloads=true&downloadRank=true)](https://nodei.co/npm/videojs-contrib-eme/) 9 | 10 | Supports Encrypted Media Extensions for playback of encrypted content in Video.js 11 | 12 | Maintenance Status: Stable 13 | 14 | 15 | 16 | 17 | - [Using](#using) 18 | - [Initialization](#initialization) 19 | - [FairPlay](#fairplay) 20 | - [Get Certificate/License by URL](#get-certificatelicense-by-url) 21 | - [Get Certificate/Content ID/License by Functions](#get-certificatecontent-idlicense-by-functions) 22 | - [PlayReady for IE11 (Windows 8.1+)](#playready-for-ie11-windows-81) 23 | - [Get License by Default](#get-license-by-default) 24 | - [Get Key by URL](#get-key-by-url) 25 | - [Get Key by Function](#get-key-by-function) 26 | - [Other DRM Systems](#other-drm-systems) 27 | - [Get License By URL](#get-license-by-url) 28 | - [Get License By Function](#get-license-by-function) 29 | - [Get Certificate by function](#get-certificate-by-function) 30 | - [audioContentType/videoContentType](#audiocontenttypevideocontenttype) 31 | - [audioRobustness/videoRobustness](#audiorobustnessvideorobustness) 32 | - [MediaKeySystemConfiguration and supportedConfigurations](#mediakeysystemconfiguration-and-supportedconfigurations) 33 | - [Get License Errors](#get-license-errors) 34 | - [API](#api) 35 | - [Available Options](#available-options) 36 | - [`keySystems`](#keysystems) 37 | - [`emeHeaders`](#emeheaders) 38 | - [`firstWebkitneedkeyTimeout`](#firstwebkitneedkeytimeout) 39 | - [`limitRenewalsMaxPauseDuration`](#limitrenewalsmaxpauseduration) 40 | - [`limitRenewalsBeforePlay`](#limitrenewalsbeforeplay) 41 | - [Setting Options per Source](#setting-options-per-source) 42 | - [Setting Options for All Sources](#setting-options-for-all-sources) 43 | - [Header Hierarchy and Removal](#header-hierarchy-and-removal) 44 | - [`emeOptions`](#emeoptions) 45 | - [`initializeMediaKeys()`](#initializemediakeys) 46 | - [`detectSupportedCDMs()`](#detectsupportedcdms) 47 | - [`initLegacyFairplay()`](#initlegacyfairplay) 48 | - [Events](#events) 49 | - [`licenserequestattempted`](#licenserequestattempted) 50 | - [`keystatuschange`](#keystatuschange) 51 | - [`keysessioncreated`](#keysessioncreated) 52 | - [License](#license) 53 | 54 | 55 | 56 | ## Using 57 | 58 | By default, videojs-contrib-eme is not able to decrypt any audio/video. 59 | 60 | In order to decrypt audio/video, a user must pass in either relevant license URIs, or methods specific to a source and its combination of key system and codec. These are provided to the plugin via either videojs-contrib-eme's plugin options or source options. 61 | 62 | If you're new to DRM on the web, read [about how EME is used to play protected content on the web](https://developers.google.com/web/fundamentals/media/eme). 63 | 64 | ### Initialization 65 | 66 | The videojs-contrib-eme plugin must be initialized before a source is loaded into the player: 67 | 68 | ```js 69 | player.eme(); 70 | player.src({ 71 | src: '', 72 | type: 'application/dash+xml', 73 | keySystems: { 74 | 'com.widevine.alpha': '' 75 | } 76 | }); 77 | ``` 78 | 79 | ### FairPlay 80 | 81 | For FairPlay, only `keySystems` is used from the options passed into videojs-contrib-eme or provided as part of the source object. 82 | 83 | There are two ways to configure FairPlay. 84 | 85 | #### Get Certificate/License by URL 86 | 87 | In this simpler implementation, you can provide URLs and allow videojs-contrib-eme to make the requests internally via default mechanisms. 88 | 89 | When using this method, there are two required properties of the `keySystems` object: 90 | 91 | * `certificateUri` 92 | * `licenseUri` 93 | 94 | And there are two _optional_ properties: 95 | 96 | * `certificateHeaders` 97 | * `licenseHeaders` 98 | 99 | With this configuration, videojs-contrib-eme will behave in the following ways: 100 | 101 | * It will fetch the certificate by making a GET request to your `certificateUri` with an expected response type of `arraybuffer`. Headers can be defined for this request via the `certificateHeaders` object. 102 | * The content ID will be interpreted from the `initData`. 103 | * It will fetch the license by making a POST request to your `licenseUri` with an expected response type of `arraybuffer`. This will have one default header of `Content-type: application/octet-stream`, but this can be overridden (or other headers added) using `licenseHeaders`. 104 | 105 | 106 | Below are examples of FairPlay configurations of this type: 107 | 108 | ```js 109 | { 110 | keySystems: { 111 | 'com.apple.fps.1_0': { 112 | certificateUri: '', 113 | licenseUri: '' 114 | } 115 | } 116 | } 117 | ``` 118 | 119 | or 120 | 121 | ```javascript 122 | { 123 | keySystems: { 124 | 'com.apple.fps.1_0': { 125 | certificateUri: '', 126 | certificateHeaders: { 127 | 'Some-Header': 'value' 128 | }, 129 | licenseUri: '', 130 | licenseHeaders: { 131 | 'Some-Header': 'value' 132 | } 133 | } 134 | } 135 | } 136 | ``` 137 | 138 | #### Get Certificate/Content ID/License by Functions 139 | 140 | You can control the license and certificate request processes by providing the following methods instead of the properties discussed above: 141 | 142 | * `getCertificate()` - Allows asynchronous retrieval of a certificate. 143 | * `getContentId()` - Allows synchronous retrieval of a content ID. It takes `emeOptions`, as well as the `initData` converted into a String. 144 | * `getLicense()` - Allows asynchronous retrieval of a license. 145 | 146 | ```js 147 | { 148 | keySystems: { 149 | 'com.apple.fps.1_0': { 150 | getCertificate: function(emeOptions, callback) { 151 | // request certificate 152 | // if err, callback(err) 153 | // if success, callback(null, certificate) 154 | }, 155 | getContentId: function(emeOptions, contentId) { 156 | // return content ID 157 | }, 158 | getLicense: function(emeOptions, contentId, keyMessage, callback) { 159 | // request key 160 | // if err, callback(err) 161 | // if success, callback(null, key) as arraybuffer 162 | } 163 | } 164 | } 165 | } 166 | ``` 167 | 168 | ### PlayReady for IE11 (Windows 8.1+) 169 | 170 | PlayReady for IE11 (Windows 8.1+) only requires `keySystems` from the options passed into videojs-contrib-eme or provided as part of the source object. 171 | 172 | There are three ways to configure PlayReady for IE11 (Windows 8.1+). 173 | 174 | #### Get License by Default 175 | 176 | If the value of `true` is provided, then a POST request will be made to the `destinationURI` passed by the message from the browser, with the headers and body specified in the message. 177 | 178 | ```js 179 | keySystems: { 180 | 'com.microsoft.playready': true 181 | } 182 | ``` 183 | 184 | #### Get Key by URL 185 | 186 | If a URL is provided - either within an object or as a string - then a POST request will be made to the provided URL, with the headers and body specified in the message. Additionally, a `licenseHeaders` object may be provided, if additional headers are required: 187 | 188 | ```js 189 | keySystems: { 190 | 'com.microsoft.playready': '' 191 | } 192 | ``` 193 | 194 | or 195 | 196 | ```js 197 | keySystems: { 198 | 'com.microsoft.playready': { 199 | url: '', 200 | licenseHeaders: { 201 | 'Some-Header': 'value' 202 | } 203 | } 204 | } 205 | ``` 206 | 207 | #### Get Key by Function 208 | 209 | If a `getKey` function is provided, then the function will be run with the message buffer and `destinationURI` passed by the browser, and will expect a callback with the key: 210 | 211 | ```js 212 | { 213 | keySystems: { 214 | 'com.microsoft.playready': { 215 | getKey: function(emeOptions, destinationURI, buffer, callback) { 216 | // request key 217 | // if err, callback(err) 218 | // if success, callback(null, key), where key is a Uint8Array 219 | } 220 | } 221 | } 222 | } 223 | ``` 224 | 225 | ### Other DRM Systems 226 | 227 | For DRM systems that use the W3C EME specification as of 5 July 2016, only `keySystems` and a way of obtaining the license are required. 228 | 229 | Obtaining a license can be done in two ways. 230 | 231 | #### Get License By URL 232 | 233 | For simple use-cases, you may use a string as the license URL or a URL as a property of in the `keySystems` entry: 234 | 235 | ```js 236 | { 237 | keySystems: { 238 | 'org.w3.clearkey': '', 239 | 'com.widevine.alpha': { 240 | url: '' 241 | } 242 | } 243 | } 244 | ``` 245 | 246 | #### Get License By Function 247 | 248 | For more complex integrations, you may pass a `getLicense` function to fully control the license retrieval process: 249 | 250 | ```js 251 | { 252 | keySystems: { 253 | 'org.w3.clearkey': { 254 | getLicense: function(emeOptions, keyMessage, callback) { 255 | // request license 256 | // if err, callback(err) 257 | // if success, callback(null, license) 258 | } 259 | } 260 | } 261 | } 262 | ``` 263 | 264 | 265 | #### Get Certificate by function 266 | Although the license acquisition is the only required configuration, `getCertificate()` is also supported if your source needs to retrieve a certificate, similar to the [FairPlay](#fairplay) implementation above. 267 | 268 | Example: 269 | ```js 270 | { 271 | 'org.w3.clearkey': { 272 | getCertificate: function(emeOptions, callback) { 273 | // request certificate 274 | // if err, callback(err) 275 | // if success, callback(null, certificate) 276 | }, 277 | } 278 | } 279 | ``` 280 | 281 | 282 | ### audioContentType/videoContentType 283 | 284 | The `audioContentType` and `videoContentType` properties for non-FairPlay sources are used to determine if the system supports that codec and to create an appropriate `keySystemAccess` object. If left out, it is possible that the system will create a `keySystemAccess` object for the given key system, but will not be able to play the source due to the browser's inability to use that codec. 285 | 286 | example: 287 | ```js 288 | { 289 | keySystems: { 290 | 'org.w3.clearkey': { 291 | audioContentType: 'audio/webm; codecs="vorbis"', 292 | videoContentType: 'video/webm; codecs="vp9"', 293 | } 294 | } 295 | } 296 | ``` 297 | 298 | ### audioRobustness/videoRobustness 299 | > If `audioRobustness`/`videoRobustness` is not passed in for widevine, you will see a warning similar to: `It is recommended that a robustness level be specified. Not specifying the robustness level could result in unexpected behavior`. Possible Options for widevine: `SW_SECURE_CRYPTO`, `SW_SECURE_DECODE`, `HW_SECURE_CRYPTO`, `HW_SECURE_DECODE`, `HW_SECURE_ALL` 300 | 301 | The `audioRobustness` and `videoRobustness` properties for non-FairPlay sources are used to determine the robustness level of the DRM you are using. 302 | 303 | Example: 304 | 305 | ```js 306 | { 307 | keySystems: { 308 | 'com.widevine.alpha': { 309 | audioRobustness: 'SW_SECURE_CRYPTO', 310 | videoRobustness: 'SW_SECURE_CRYPTO' 311 | } 312 | } 313 | } 314 | ``` 315 | 316 | ### MediaKeySystemConfiguration and supportedConfigurations 317 | 318 | In addition to key systems options provided above, it is possible to directly provide the `supportedConfigurations` array to use for the `requestMediaKeySystemAccess` call. This allows for the entire range of options specified by the [MediaKeySystemConfiguration] object. 319 | 320 | Note that if `supportedConfigurations` is provided, it will override `audioContentType`, `videoContentType`, `audioRobustness`, `videoRobustness`, and `persistentState`. 321 | 322 | Example: 323 | 324 | ```js 325 | { 326 | keySystems: { 327 | 'org.w3.clearkey': { 328 | supportedConfigurations: [{ 329 | videoCapabilities: [{ 330 | contentType: 'video/webm; codecs="vp9"', 331 | robustness: 'SW_SECURE_CRYPTO' 332 | }], 333 | audioCapabilities: [{ 334 | contentType: 'audio/webm; codecs="vorbis"', 335 | robustness: 'SW_SECURE_CRYPTO' 336 | }] 337 | }], 338 | 'org.w3.clearkey': '' 339 | } 340 | } 341 | } 342 | ``` 343 | 344 | ### Get License Errors 345 | 346 | The default `getLicense()` functions pass an error to the callback if the license request returns a 4xx or 5xx response code. Depending on how the license server is configured, it is possible in some cases that a valid license could still be returned even if the response code is in that range. If you wish not to pass an error for 4xx and 5xx response codes, you may pass your own `getLicense()` function with the `keySystems` as described above. 347 | 348 | ## API 349 | 350 | ### Available Options 351 | 352 | #### `keySystems` 353 | 354 | This is the main option through which videojs-contrib-eme can be configured. It maps key systems by name (e.g. `'org.w3.clearkey'`) to an object for configuring that key system. 355 | 356 | #### `emeHeaders` 357 | 358 | This object can be a convenient way to specify default headers for _all_ requests that are made by videojs-contrib-eme. These headers will override any headers that are set by videojs-contrib-eme internally, but can be further overridden by headers specified in `keySystems` objects (e.g., `certificateHeaders` or `licenseHeaders`). 359 | 360 | An `emeHeaders` object should look like this: 361 | 362 | ```js 363 | emeHeaders: { 364 | 'Common-Header': 'value' 365 | } 366 | ``` 367 | 368 | #### `firstWebkitneedkeyTimeout` 369 | > Default: 1000 370 | 371 | The amount of time in milliseconds to wait on the first `webkitneedkey` event before making the key request. This was implemented due to a bug in Safari where rendition switches at the start of playback can cause `webkitneedkey` to fire multiple times, with only the last one being valid. 372 | 373 | #### `limitRenewalsMaxPauseDuration` 374 | 375 | The duration, in seconds, to wait in paused state before license-renewals are rejected and session is closed. This option limits excess license requests when using limited-duration licenses. 376 | 377 | #### `limitRenewalsBeforePlay` 378 | > Boolean 379 | 380 | If set to true, license renewal is rejected if license expires before play when player is idle. This option limits excess license requests when using limited-duration licenses. 381 | 382 | ### Setting Options per Source 383 | 384 | This is the recommended way of setting most options. Each source may have a different set of requirements; so, it is best to define options on a per source basis. 385 | 386 | To do this, attach the options to the source object you pass to `player.src()`: 387 | 388 | ```js 389 | player.src({ 390 | 391 | // normal Video.js src and type options 392 | src: '', 393 | type: 'video/webm', 394 | 395 | // eme options 396 | emeHeaders: { 397 | 'Common-Header': 'value' 398 | }, 399 | keySystems: { 400 | 'org.w3.clearkey': { 401 | initDataTypes: ['cenc', 'webm'], 402 | audioContentType: 'audio/webm; codecs="vorbis"', 403 | videoContentType: 'video/webm; codecs="vp9"', 404 | getCertificate: function(emeOptions, callback) { 405 | // request certificate 406 | // if err, callback(err) 407 | // if success, callback(null, certificate) 408 | }, 409 | getLicense: function(emeOptions, keyMessage, callback) { 410 | // request license 411 | // if err, callback(err) 412 | // if success, callback(null, license) 413 | } 414 | } 415 | } 416 | }); 417 | ``` 418 | 419 | ### Setting Options for All Sources 420 | 421 | While [setting options per source](#setting-options-per-source) is recommended, some implementations may want to use plugin-level options. 422 | 423 | These can be set during plugin invocation: 424 | 425 | ```js 426 | player.eme({ 427 | 428 | // Set Common-Header on ALL requests for ALL key systems. 429 | emeHeaders: { 430 | 'Common-Header': 'value' 431 | } 432 | }); 433 | ``` 434 | 435 | Plugin-level options may also be set after plugin initialization by assigning to the options property on the `eme` object itself: 436 | 437 | ```js 438 | player.eme(); 439 | 440 | player.eme.options.emeHeaders = { 441 | 'Common-Header': 'value' 442 | }; 443 | ``` 444 | 445 | or 446 | 447 | ```js 448 | player.eme(); 449 | 450 | player.eme.options = { 451 | emeHeaders: { 452 | 'Common-Header': 'value' 453 | } 454 | }; 455 | ``` 456 | 457 | ### Header Hierarchy and Removal 458 | 459 | Headers defined in the `emeHeaders` option or in `licenseHeaders`/`certificateHeaders` objects within `keySystems` can _remove_ headers defined at lower levels without defining a new value. This can be done by setting their value to `null`. 460 | 461 | The hierarchy of header definitions is: 462 | 463 | ``` 464 | licenseHeaders/certificateHeaders > emeHeaders > internal defaults 465 | ``` 466 | 467 | In most cases, the header `{'Content-type': 'application/octet-stream'}` is a default and cannot be overridden without writing your own `getLicense()` function. This internal default can be overridden by either of the user-provided options. 468 | 469 | Here's an example: 470 | 471 | ```js 472 | player.eme({ 473 | emeHeaders: { 474 | 475 | // Remove the internal default Content-Type 476 | 'Content-Type': null, 477 | 'Custom-Foo': '' 478 | } 479 | }); 480 | 481 | player.src({ 482 | src: '', 483 | type: '', 484 | keySystems: { 485 | 'com.apple.fps.1_0': { 486 | certificateUri: '', 487 | certificateHeaders: { 488 | 'Custom-Foo': '' 489 | }, 490 | licenseUri: '', 491 | licenseHeaders: { 492 | 'License-Bar': '' 493 | } 494 | } 495 | } 496 | }) 497 | ``` 498 | 499 | This will result in the following headers for the certificate request: 500 | 501 | ``` 502 | Custom-Foo: 503 | ``` 504 | 505 | And for the license request: 506 | 507 | ``` 508 | Custom-Foo: 509 | License-Bar: 510 | ``` 511 | 512 | ### `emeOptions` 513 | 514 | All methods in a key system receive `emeOptions` as their first argument. 515 | 516 | The `emeOptions` are an object which merges source-level options with plugin-level options. 517 | 518 | > **NOTE:** In these cases, plugin-level options will **override** the source-level options. This is used by libraries like [VHS](https://github.com/videojs/http-streaming), but could be unintuitive. This is another reason to prefer source-level options in all cases! 519 | 520 | It is available to make it easier to access options in custom key systems methods, so that you don't have to maintain your own references. 521 | 522 | For example, if you needed to use a `userId` for the `getCertificate()` request, you could: 523 | 524 | ```js 525 | player.eme(); 526 | 527 | player.src({ 528 | keySystems: { 529 | 'org.w3.clearkey': { 530 | getCertificate: function(emeOptions, callback) { 531 | var userId = emeOptions.userId; // 'user-id' 532 | // ... 533 | }, 534 | getLicense: function(emeOptions, keyMessage, callback) { 535 | var userId = emeOptions.userId; // 'user-id' 536 | // ... 537 | } 538 | } 539 | }, 540 | userId: 'user-id' 541 | }); 542 | ``` 543 | 544 | ### `initializeMediaKeys()` 545 | 546 | `player.eme.initializeMediaKeys()` sets up MediaKeys immediately on demand. 547 | 548 | This is useful for setting up the video element for DRM before loading any content. Otherwise, the video element is set up for DRM on `encrypted` events. This is not supported in Safari. 549 | 550 | ```js 551 | // additional plugin options 552 | var emeOptions = { 553 | keySystems: { 554 | 'org.w3.clearkey': {...} 555 | } 556 | }; 557 | 558 | var emeCallback = function(error) { 559 | if (error) { 560 | // do something with error 561 | } 562 | 563 | // do something else 564 | }; 565 | 566 | var suppressErrorsIfPossible = true; 567 | 568 | player.eme.initializeMediaKeys(emeOptions, emeCallback, suppressErrorsIfPossible); 569 | ``` 570 | 571 | When `suppressErrorsIfPossible` is set to `false` (the default) and an error occurs, the error handler will be invoked after the callback finishes and `error()` will be called on the player. When set to `true` and an error occurs, the error handler will not be invoked with the exception of `mskeyerror` errors in IE11 since they cannot be suppressed asynchronously. 572 | 573 | ### `detectSupportedCDMs()` 574 | 575 | `player.eme.detectSupportedCDMs()` is used to asynchronously detect and return a list of supported Content Decryption Modules (CDMs) in the current browser. It uses the EME API to request access to each key system and determine its availability. This function checks for the support of the following key systems: FairPlay, PlayReady, Widevine, and ClearKey. 576 | 577 | Please use this function sparingly, as side-effects (namely calling `navigator.requestMediaKeySystemAccess()`) can have user-visible effects, such as prompting for system resource permissions, which could be disruptive if invoked at inappropriate times. See [requestMediaKeySystemAccess()](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/requestMediaKeySystemAccess) documentation for more information. 578 | 579 | ```js 580 | player.eme.detectSupportedCDMs() 581 | .then(supportedCDMs => { 582 | // Sample output: {fairplay: false, playready: false, widevine: true, clearkey: true} 583 | console.log(supportedCDMs); 584 | }); 585 | ``` 586 | 587 | ### `initLegacyFairplay()` 588 | 589 | `player.eme.initLegacyFairplay()` is used to init the `'webkitneedskey'` listener when using `WebKitMediaKeys` in Safari. This is useful because Safari currently supports both the modern `com.apple.fps` keysystem through `MediaKeys` and the legacy `com.apple.fps.1_0` keysystem through `WebKitMediaKeys`. Since this plugin will prefer using modern `MediaKeys` over `WebkitMediaKeys` initializing legacy fairplay can be necessary for media using the legacy `1_0` keysystem. 590 | 591 | ```js 592 | player.eme.initLegacyFairplay(); 593 | ``` 594 | _________________________________________________________ 595 | 596 | ### Events 597 | 598 | There are some events that are specific to this plugin. 599 | 600 | #### `licenserequestattempted` 601 | 602 | This event is triggered on the Video.js playback tech on the callback of every license request made by videojs-contrib-eme. 603 | 604 | ``` 605 | player.tech(true).on('licenserequestattempted', function(event) { 606 | // Act on event 607 | }); 608 | ``` 609 | 610 | #### `keystatuschange` 611 | 612 | When the status of a key changes, an event of type `keystatuschange` will 613 | be triggered on the Video.js playback tech. This helps you handle feedback to the user for situations like trying to play DRM-protected media on restricted devices. 614 | 615 | ``` 616 | player.tech(true).on('keystatuschange', function(event) { 617 | // Event data: 618 | // keyId 619 | // status: usable, output-restricted, etc 620 | // target: the MediaKeySession object that caused this event 621 | }); 622 | ``` 623 | 624 | This event is triggered directly from the underlying `keystatuseschange` event, so the statuses should correspond to [those listed in the spec](https://www.w3.org/TR/encrypted-media/#dom-mediakeystatus). 625 | 626 | ### `keysessioncreated` 627 | 628 | When the key session is created, an event of type `keysessioncreated` will be triggered on the Video.js playback tech. 629 | 630 | ``` 631 | player.tech().on('keysessioncreated', function(keySession) { 632 | // Event data: 633 | // keySession: the mediaKeySession object 634 | // https://www.w3.org/TR/encrypted-media/#mediakeysession-interface 635 | }); 636 | ``` 637 | 638 | ## License 639 | 640 | Apache License, Version 2.0. [View the license file](LICENSE) 641 | 642 | [MediaKeySystemConfiguration]: https://www.w3.org/TR/encrypted-media/#dom-mediakeysystemconfiguration 643 | -------------------------------------------------------------------------------- /index-player-options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | videojs-contrib-eme Demo 6 | 7 | 8 | 9 | 12 | 16 | 17 | 18 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | videojs-contrib-eme Demo 6 | 7 | 8 | 9 | 11 | 12 | 15 | 16 | 17 | 18 | 19 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /moose_encrypted.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/videojs/videojs-contrib-eme/8ff427619b00dee2a93aa4dd5472d6814f34b70d/moose_encrypted.webm -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "videojs-contrib-eme", 3 | "version": "5.5.1", 4 | "description": "Supports Encrypted Media Extensions for playback of encrypted content in Video.js", 5 | "main": "dist/videojs-contrib-eme.cjs.js", 6 | "module": "dist/videojs-contrib-eme.es.js", 7 | "scripts": { 8 | "prebuild": "npm run clean", 9 | "build": "npm-run-all -p build:*", 10 | "build:js": "rollup -c scripts/rollup.config.js", 11 | "clean": "shx rm -rf ./dist ./test/dist", 12 | "postclean": "shx mkdir -p ./dist ./test/dist", 13 | "docs": "npm-run-all docs:*", 14 | "docs:api": "jsdoc src -c scripts/jsdoc.config.json -r -d docs/api", 15 | "docs:toc": "doctoc README.md", 16 | "lint": "vjsstandard", 17 | "server": "karma start scripts/karma.conf.js --singleRun=false --auto-watch", 18 | "start": "npm-run-all -p server watch", 19 | "pretest": "npm-run-all lint build", 20 | "test": "karma start scripts/karma.conf.js", 21 | "posttest": "shx cat test/dist/coverage/text.txt", 22 | "update-changelog": "conventional-changelog -p videojs -i CHANGELOG.md -s", 23 | "preversion": "npm test", 24 | "version": "is-prerelease || npm run update-changelog && git add CHANGELOG.md", 25 | "watch": "npm-run-all -p watch:*", 26 | "watch:js": "npm run build:js -- -w", 27 | "prepublishOnly": "npm run build && vjsverify --skip-es-check" 28 | }, 29 | "keywords": [ 30 | "videojs", 31 | "videojs-plugin" 32 | ], 33 | "copyright": "Copyright Brightcove, Inc. ", 34 | "license": "Apache-2.0", 35 | "vjsstandard": { 36 | "ignore": [ 37 | "dist", 38 | "docs", 39 | "test/dist" 40 | ] 41 | }, 42 | "files": [ 43 | "CONTRIBUTING.md", 44 | "dist/", 45 | "docs/", 46 | "index.html", 47 | "scripts/", 48 | "src/", 49 | "test/" 50 | ], 51 | "dependencies": { 52 | "global": "^4.3.2" 53 | }, 54 | "peerDependencies": { 55 | "video.js": "^8.11.8" 56 | }, 57 | "devDependencies": { 58 | "conventional-changelog-cli": "^2.0.12", 59 | "conventional-changelog-videojs": "^3.0.0", 60 | "doctoc": "^2.2.0", 61 | "husky": "^1.0.0-rc.13", 62 | "jsdoc": "^3.6.10", 63 | "karma": "^6.4.0", 64 | "lint-staged": "^7.2.2", 65 | "not-prerelease": "^1.0.1", 66 | "npm-merge-driver-install": "^3.0.0", 67 | "npm-run-all": "^4.1.3", 68 | "pkg-ok": "^2.2.0", 69 | "rollup": "^2.2.0", 70 | "shx": "^0.3.2", 71 | "sinon": "^6.1.5", 72 | "videojs-generate-karma-config": "^8.0.1", 73 | "videojs-generate-rollup-config": "^7.0.0", 74 | "videojs-generator-verify": "^1.2.0", 75 | "videojs-standard": "~9.1.0" 76 | }, 77 | "generator-videojs-plugin": { 78 | "version": "7.3.2" 79 | }, 80 | "author": "brandonocasey ", 81 | "husky": { 82 | "hooks": { 83 | "pre-commit": "lint-staged" 84 | } 85 | }, 86 | "lint-staged": { 87 | "*.js": [ 88 | "vjsstandard --fix", 89 | "git add" 90 | ], 91 | "README.md": [ 92 | "npm run docs:toc", 93 | "git add" 94 | ] 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /scripts/jsdoc.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["plugins/markdown"] 3 | } 4 | 5 | -------------------------------------------------------------------------------- /scripts/karma.conf.js: -------------------------------------------------------------------------------- 1 | const generate = require('videojs-generate-karma-config'); 2 | 3 | module.exports = function(config) { 4 | 5 | // see https://github.com/videojs/videojs-generate-karma-config 6 | // for options 7 | const options = { 8 | browsers(aboutToRun) { 9 | // TODO - current firefox headless fails to run w karma, blocking the npm version script. 10 | // We should look into a better workaround that allows us to still run firefox through karma 11 | // See https://github.com/karma-runner/karma-firefox-launcher/issues/328 12 | return aboutToRun.filter(function(launcherName) { 13 | return launcherName !== 'FirefoxHeadless'; 14 | }); 15 | } 16 | }; 17 | 18 | config = generate(config, options); 19 | 20 | // any other custom stuff not supported by options here! 21 | }; 22 | 23 | -------------------------------------------------------------------------------- /scripts/rollup.config.js: -------------------------------------------------------------------------------- 1 | const generate = require('videojs-generate-rollup-config'); 2 | 3 | // see https://github.com/videojs/videojs-generate-rollup-config 4 | // for options 5 | const options = {}; 6 | const config = generate(options); 7 | 8 | // Add additonal builds/customization here! 9 | 10 | // export the builds to rollup 11 | export default Object.values(config.builds); 12 | -------------------------------------------------------------------------------- /src/cdm.js: -------------------------------------------------------------------------------- 1 | import window from 'global/window'; 2 | 3 | const genericConfig = [{ 4 | initDataTypes: ['cenc'], 5 | audioCapabilities: [{ 6 | contentType: 'audio/mp4;codecs="mp4a.40.2"' 7 | }], 8 | videoCapabilities: [{ 9 | contentType: 'video/mp4;codecs="avc1.42E01E"' 10 | }] 11 | }]; 12 | 13 | const keySystems = [ 14 | // Fairplay 15 | // Requires different config than other CDMs 16 | { 17 | keySystem: 'com.apple.fps', 18 | supportedConfig: [{ 19 | initDataTypes: ['sinf'], 20 | videoCapabilities: [{ 21 | contentType: 'video/mp4' 22 | }] 23 | }] 24 | }, 25 | // Playready 26 | { 27 | keySystem: 'com.microsoft.playready.recommendation', 28 | supportedConfig: genericConfig 29 | }, 30 | // Widevine 31 | { 32 | keySystem: 'com.widevine.alpha', 33 | supportedConfig: genericConfig 34 | }, 35 | // Clear 36 | { 37 | keySystem: 'org.w3.clearkey', 38 | supportedConfig: genericConfig 39 | } 40 | ]; 41 | 42 | // Asynchronously detect the list of supported CDMs by requesting key system access 43 | // when possible, otherwise rely on browser-specific EME API feature detection. 44 | export const detectSupportedCDMs = () => { 45 | const Promise = window.Promise; 46 | const results = { 47 | fairplay: Boolean(window.WebKitMediaKeys), 48 | playready: false, 49 | widevine: false, 50 | clearkey: false 51 | }; 52 | 53 | if (!window.MediaKeys || !window.navigator.requestMediaKeySystemAccess) { 54 | return Promise.resolve(results); 55 | } 56 | 57 | return Promise.all(keySystems.map(({keySystem, supportedConfig}) => { 58 | return window.navigator.requestMediaKeySystemAccess(keySystem, supportedConfig).catch(() => {}); 59 | })).then(([fairplay, playready, widevine, clearkey]) => { 60 | results.fairplay = Boolean(fairplay); 61 | results.playready = Boolean(playready); 62 | results.widevine = Boolean(widevine); 63 | results.clearkey = Boolean(clearkey); 64 | 65 | return results; 66 | }); 67 | }; 68 | -------------------------------------------------------------------------------- /src/consts/errors.js: -------------------------------------------------------------------------------- 1 | const Error = { 2 | EMEFailedToRequestMediaKeySystemAccess: 'eme-failed-request-media-key-system-access', 3 | EMEFailedToCreateMediaKeys: 'eme-failed-create-media-keys', 4 | EMEFailedToAttachMediaKeysToVideoElement: 'eme-failed-attach-media-keys-to-video', 5 | EMEFailedToCreateMediaKeySession: 'eme-failed-create-media-key-session', 6 | EMEFailedToSetServerCertificate: 'eme-failed-set-server-certificate', 7 | EMEFailedToGenerateLicenseRequest: 'eme-failed-generate-license-request', 8 | EMEFailedToUpdateSessionWithReceivedLicenseKeys: 'eme-failed-update-session', 9 | EMEFailedToCloseSession: 'eme-failed-close-session', 10 | EMEFailedToRemoveKeysFromSession: 'eme-failed-remove-keys', 11 | EMEFailedToLoadSessionBySessionId: 'eme-failed-load-session' 12 | }; 13 | 14 | export default Error; 15 | -------------------------------------------------------------------------------- /src/eme.js: -------------------------------------------------------------------------------- 1 | import videojs from 'video.js'; 2 | import { requestPlayreadyLicense } from './playready'; 3 | import window from 'global/window'; 4 | import {uint8ArrayToString, mergeAndRemoveNull} from './utils'; 5 | import {httpResponseHandler} from './http-handler.js'; 6 | import { 7 | defaultGetCertificate as defaultFairplayGetCertificate, 8 | defaultGetLicense as defaultFairplayGetLicense, 9 | defaultGetContentId as defaultFairplayGetContentId 10 | } from './fairplay'; 11 | import EmeError from './consts/errors'; 12 | 13 | const isFairplayKeySystem = (str) => str.startsWith('com.apple.fps'); 14 | 15 | /** 16 | * Trigger an event on the event bus component safely. 17 | * 18 | * This is used because there are cases where we can see race conditions 19 | * between asynchronous operations (like closing a key session) and the 20 | * availability of the event bus's DOM element. 21 | * 22 | * @param {Component} eventBus 23 | * @param {...} args 24 | */ 25 | export const safeTriggerOnEventBus = (eventBus, args) => { 26 | if (eventBus.isDisposed()) { 27 | return; 28 | } 29 | 30 | eventBus.trigger({...args}); 31 | }; 32 | 33 | /** 34 | * Returns an array of MediaKeySystemConfigurationObjects provided in the keySystem 35 | * options. 36 | * 37 | * @see {@link https://www.w3.org/TR/encrypted-media/#dom-mediakeysystemconfiguration|MediaKeySystemConfigurationObject} 38 | * 39 | * @param {Object} keySystemOptions 40 | * Options passed into videojs-contrib-eme for a specific keySystem 41 | * @return {Object[]} 42 | * Array of MediaKeySystemConfigurationObjects 43 | */ 44 | export const getSupportedConfigurations = (keySystem, keySystemOptions) => { 45 | if (keySystemOptions.supportedConfigurations) { 46 | return keySystemOptions.supportedConfigurations; 47 | } 48 | 49 | const isFairplay = isFairplayKeySystem(keySystem); 50 | const supportedConfiguration = {}; 51 | const initDataTypes = keySystemOptions.initDataTypes || 52 | // fairplay requires an explicit initDataTypes 53 | (isFairplay ? ['sinf'] : null); 54 | const audioContentType = keySystemOptions.audioContentType; 55 | const audioRobustness = keySystemOptions.audioRobustness; 56 | const videoContentType = keySystemOptions.videoContentType || 57 | // fairplay requires an explicit videoCapabilities/videoContentType 58 | (isFairplay ? 'video/mp4' : null); 59 | const videoRobustness = keySystemOptions.videoRobustness; 60 | const persistentState = keySystemOptions.persistentState; 61 | 62 | if (audioContentType || audioRobustness) { 63 | supportedConfiguration.audioCapabilities = [ 64 | Object.assign( 65 | {}, 66 | (audioContentType ? { contentType: audioContentType } : {}), 67 | (audioRobustness ? { robustness: audioRobustness } : {}) 68 | ) 69 | ]; 70 | } 71 | 72 | if (videoContentType || videoRobustness) { 73 | supportedConfiguration.videoCapabilities = [ 74 | Object.assign( 75 | {}, 76 | (videoContentType ? { contentType: videoContentType } : {}), 77 | (videoRobustness ? { robustness: videoRobustness } : {}) 78 | ) 79 | ]; 80 | } 81 | 82 | if (persistentState) { 83 | supportedConfiguration.persistentState = persistentState; 84 | } 85 | 86 | if (initDataTypes) { 87 | supportedConfiguration.initDataTypes = initDataTypes; 88 | } 89 | 90 | return [supportedConfiguration]; 91 | }; 92 | 93 | export const getSupportedKeySystem = (keySystems) => { 94 | // As this happens after the src is set on the video, we rely only on the set src (we 95 | // do not change src based on capabilities of the browser in this plugin). 96 | 97 | let promise; 98 | 99 | Object.keys(keySystems).forEach((keySystem) => { 100 | const supportedConfigurations = getSupportedConfigurations(keySystem, keySystems[keySystem]); 101 | 102 | if (!promise) { 103 | promise = 104 | window.navigator.requestMediaKeySystemAccess(keySystem, supportedConfigurations); 105 | } else { 106 | promise = promise.catch((e) => 107 | window.navigator.requestMediaKeySystemAccess(keySystem, supportedConfigurations)); 108 | } 109 | }); 110 | 111 | return promise; 112 | }; 113 | 114 | export const makeNewRequest = (player, requestOptions) => { 115 | const { 116 | mediaKeys, 117 | initDataType, 118 | initData, 119 | options, 120 | getLicense, 121 | removeSession, 122 | eventBus, 123 | contentId, 124 | emeError, 125 | keySystem 126 | } = requestOptions; 127 | 128 | let timeElapsed = 0; 129 | let pauseTimer; 130 | 131 | player.on('pause', () => { 132 | if (options.limitRenewalsMaxPauseDuration && typeof options.limitRenewalsMaxPauseDuration === 'number') { 133 | 134 | pauseTimer = setInterval(() => { 135 | timeElapsed++; 136 | if (timeElapsed >= options.limitRenewalsMaxPauseDuration) { 137 | clearInterval(pauseTimer); 138 | } 139 | }, 1000); 140 | 141 | player.on('play', () => { 142 | clearInterval(pauseTimer); 143 | timeElapsed = 0; 144 | }); 145 | } 146 | }); 147 | 148 | try { 149 | const keySession = mediaKeys.createSession(); 150 | 151 | const closeAndRemoveSession = () => { 152 | videojs.log.debug('Session expired, closing the session.'); 153 | keySession.close().then(() => { 154 | 155 | // Because close() is async, this promise could resolve after the 156 | // player has been disposed. 157 | if (eventBus.isDisposed()) { 158 | return; 159 | } 160 | 161 | safeTriggerOnEventBus(eventBus, { 162 | type: 'keysessionclosed', 163 | keySession 164 | }); 165 | removeSession(initData); 166 | }).catch((error) => { 167 | const metadata = { 168 | errorType: EmeError.EMEFailedToCloseSession, 169 | keySystem 170 | }; 171 | 172 | emeError(error, metadata); 173 | }); 174 | }; 175 | 176 | safeTriggerOnEventBus(eventBus, { 177 | type: 'keysessioncreated', 178 | keySession 179 | }); 180 | 181 | player.on('dispose', () => { 182 | closeAndRemoveSession(); 183 | }); 184 | 185 | return new Promise((resolve, reject) => { 186 | keySession.addEventListener('message', (event) => { 187 | safeTriggerOnEventBus(eventBus, { 188 | type: 'keymessage', 189 | messageEvent: event 190 | }); 191 | // all other types will be handled by keystatuseschange 192 | if (event.messageType !== 'license-request' && event.messageType !== 'license-renewal') { 193 | return; 194 | } 195 | 196 | if (event.messageType === 'license-renewal') { 197 | const limitRenewalsBeforePlay = options.limitRenewalsBeforePlay; 198 | const limitRenewalsMaxPauseDuration = options.limitRenewalsMaxPauseDuration; 199 | const validLimitRenewalsMaxPauseDuration = typeof limitRenewalsMaxPauseDuration === 'number'; 200 | const renewingBeforePlayback = !player.hasStarted() && limitRenewalsBeforePlay; 201 | const maxPauseDurationReached = player.paused() && validLimitRenewalsMaxPauseDuration && timeElapsed >= limitRenewalsMaxPauseDuration; 202 | const ended = player.ended(); 203 | 204 | if (renewingBeforePlayback || maxPauseDurationReached || ended) { 205 | closeAndRemoveSession(); 206 | return; 207 | } 208 | } 209 | 210 | getLicense(options, event.message, contentId) 211 | .then((license) => { 212 | resolve(keySession.update(license).then(() => { 213 | safeTriggerOnEventBus(eventBus, { 214 | type: 'keysessionupdated', 215 | keySession 216 | }); 217 | }).catch((error) => { 218 | const metadata = { 219 | errorType: EmeError.EMEFailedToUpdateSessionWithReceivedLicenseKeys, 220 | keySystem 221 | }; 222 | 223 | emeError(error, metadata); 224 | })); 225 | }) 226 | .catch((err) => { 227 | reject(err); 228 | }); 229 | }, false); 230 | 231 | const KEY_STATUSES_CHANGE = 'keystatuseschange'; 232 | 233 | keySession.addEventListener(KEY_STATUSES_CHANGE, (event) => { 234 | let expired = false; 235 | 236 | // Protect from race conditions causing the player to be disposed. 237 | if (eventBus.isDisposed()) { 238 | return; 239 | } 240 | 241 | // Re-emit the keystatuseschange event with the entire keyStatusesMap 242 | safeTriggerOnEventBus(eventBus, { 243 | type: KEY_STATUSES_CHANGE, 244 | keyStatuses: keySession.keyStatuses 245 | }); 246 | 247 | // Keep 'keystatuschange' for backward compatibility. 248 | // based on https://www.w3.org/TR/encrypted-media/#example-using-all-events 249 | keySession.keyStatuses.forEach((status, keyId) => { 250 | // Trigger an event so that outside listeners can take action if appropriate. 251 | // For instance, the `output-restricted` status should result in an 252 | // error being thrown. 253 | safeTriggerOnEventBus(eventBus, { 254 | keyId, 255 | status, 256 | target: keySession, 257 | type: 'keystatuschange' 258 | }); 259 | switch (status) { 260 | case 'expired': 261 | // If one key is expired in a session, all keys are expired. From 262 | // https://www.w3.org/TR/encrypted-media/#dom-mediakeystatus-expired, "All other 263 | // keys in the session must have this status." 264 | expired = true; 265 | break; 266 | case 'internal-error': 267 | const message = 268 | 'Key status reported as "internal-error." Leaving the session open since we ' + 269 | 'don\'t have enough details to know if this error is fatal.'; 270 | 271 | // "This value is not actionable by the application." 272 | // https://www.w3.org/TR/encrypted-media/#dom-mediakeystatus-internal-error 273 | videojs.log.warn(message, event); 274 | break; 275 | } 276 | }); 277 | 278 | if (expired) { 279 | // Close session and remove it from the session list to ensure that a new 280 | // session can be created. 281 | closeAndRemoveSession(); 282 | } 283 | }, false); 284 | 285 | keySession.generateRequest(initDataType, initData).catch((error) => { 286 | const metadata = { 287 | errorType: EmeError.EMEFailedToGenerateLicenseRequest, 288 | keySystem 289 | }; 290 | 291 | emeError(error, metadata); 292 | reject('Unable to create or initialize key session'); 293 | }); 294 | }); 295 | 296 | } catch (error) { 297 | const metadata = { 298 | errorType: EmeError.EMEFailedToCreateMediaKeySession, 299 | keySystem 300 | }; 301 | 302 | emeError(error, metadata); 303 | } 304 | }; 305 | 306 | /* 307 | * Creates a new media key session if media keys are available, otherwise queues the 308 | * session creation for when the media keys are available. 309 | * 310 | * @see {@link https://www.w3.org/TR/encrypted-media/#dom-mediakeysession|MediaKeySession} 311 | * @see {@link https://www.w3.org/TR/encrypted-media/#dom-mediakeys|MediaKeys} 312 | * 313 | * @function addSession 314 | * @param {Object} video 315 | * Target video element 316 | * @param {string} initDataType 317 | * The type of init data provided 318 | * @param {Uint8Array} initData 319 | * The media's init data 320 | * @param {Object} options 321 | * Options provided to the plugin for this key system 322 | * @param {function()} [getLicense] 323 | * User provided function to retrieve a license 324 | * @param {function()} removeSession 325 | * Function to remove the persisted session on key expiration so that a new session 326 | * may be created 327 | * @param {Object} eventBus 328 | * Event bus for any events pertinent to users 329 | * @return {Promise} 330 | * A resolved promise if session is waiting for media keys, or a promise for the 331 | * session creation if media keys are available 332 | */ 333 | export const addSession = ({ 334 | player, 335 | video, 336 | initDataType, 337 | initData, 338 | options, 339 | getLicense, 340 | contentId, 341 | removeSession, 342 | eventBus, 343 | emeError 344 | }) => { 345 | const sessionData = { 346 | initDataType, 347 | initData, 348 | options, 349 | getLicense, 350 | removeSession, 351 | eventBus, 352 | contentId, 353 | emeError, 354 | keySystem: video.keySystem 355 | }; 356 | 357 | if (video.mediaKeysObject) { 358 | sessionData.mediaKeys = video.mediaKeysObject; 359 | return makeNewRequest(player, sessionData); 360 | } 361 | 362 | video.pendingSessionData.push(sessionData); 363 | 364 | return Promise.resolve(); 365 | }; 366 | 367 | /* 368 | * Given media keys created from a key system access object, check for any session data 369 | * that was queued and create new sessions for each. 370 | * 371 | * @see {@link https://www.w3.org/TR/encrypted-media/#dom-mediakeysystemaccess|MediaKeySystemAccess} 372 | * @see {@link https://www.w3.org/TR/encrypted-media/#dom-mediakeysession|MediaKeySession} 373 | * @see {@link https://www.w3.org/TR/encrypted-media/#dom-mediakeys|MediaKeys} 374 | * 375 | * @function addPendingSessions 376 | * @param {Object} video 377 | * Target video element 378 | * @param {string} [certificate] 379 | * The server certificate (if used) 380 | * @param {Object} createdMediaKeys 381 | * Media keys to use for session creation 382 | * @return {Promise} 383 | * A promise containing new session creations and setting of media keys on the 384 | * video object 385 | */ 386 | export const addPendingSessions = ({ 387 | player, 388 | video, 389 | certificate, 390 | createdMediaKeys, 391 | emeError 392 | }) => { 393 | // save media keys on the video element to act as a reference for other functions so 394 | // that they don't recreate the keys 395 | video.mediaKeysObject = createdMediaKeys; 396 | const promises = []; 397 | 398 | if (certificate) { 399 | promises.push(createdMediaKeys.setServerCertificate(certificate).catch((error) => { 400 | const metadata = { 401 | errorType: EmeError.EMEFailedToSetServerCertificate, 402 | keySystem: video.keySystem 403 | }; 404 | 405 | emeError(error, metadata); 406 | })); 407 | } 408 | 409 | for (let i = 0; i < video.pendingSessionData.length; i++) { 410 | const data = video.pendingSessionData[i]; 411 | 412 | promises.push(makeNewRequest(player, { 413 | mediaKeys: video.mediaKeysObject, 414 | initDataType: data.initDataType, 415 | initData: data.initData, 416 | options: data.options, 417 | getLicense: data.getLicense, 418 | removeSession: data.removeSession, 419 | eventBus: data.eventBus, 420 | contentId: data.contentId, 421 | emeError: data.emeError, 422 | keySystem: video.keySystem 423 | })); 424 | } 425 | 426 | video.pendingSessionData = []; 427 | 428 | promises.push(video.setMediaKeys(createdMediaKeys).catch((error) => { 429 | const metadata = { 430 | errorType: EmeError.EMEFailedToAttachMediaKeysToVideoElement, 431 | keySystem: video.keySystem 432 | }; 433 | 434 | emeError(error, metadata); 435 | })); 436 | 437 | return Promise.all(promises); 438 | }; 439 | 440 | const defaultPlayreadyGetLicense = (keySystem, keySystemOptions) => (emeOptions, keyMessage, callback) => { 441 | requestPlayreadyLicense(keySystem, keySystemOptions, keyMessage, emeOptions, callback); 442 | }; 443 | 444 | export const defaultGetLicense = (keySystem, keySystemOptions) => (emeOptions, keyMessage, callback) => { 445 | const headers = mergeAndRemoveNull( 446 | {'Content-type': 'application/octet-stream'}, 447 | emeOptions.emeHeaders, 448 | keySystemOptions.licenseHeaders 449 | ); 450 | 451 | videojs.xhr({ 452 | uri: keySystemOptions.url, 453 | method: 'POST', 454 | responseType: 'arraybuffer', 455 | requestType: 'license', 456 | metadata: { keySystem }, 457 | body: keyMessage, 458 | headers 459 | }, httpResponseHandler(callback, true)); 460 | }; 461 | 462 | const promisifyGetLicense = (keySystem, getLicenseFn, eventBus) => { 463 | return (emeOptions, keyMessage, contentId) => { 464 | return new Promise((resolve, reject) => { 465 | const callback = function(err, license) { 466 | if (eventBus) { 467 | safeTriggerOnEventBus(eventBus, { type: 'licenserequestattempted' }); 468 | } 469 | if (err) { 470 | reject(err); 471 | return; 472 | } 473 | 474 | resolve(license); 475 | }; 476 | 477 | if (isFairplayKeySystem(keySystem)) { 478 | getLicenseFn(emeOptions, contentId, new Uint8Array(keyMessage), callback); 479 | } else { 480 | getLicenseFn(emeOptions, keyMessage, callback); 481 | } 482 | }); 483 | }; 484 | }; 485 | 486 | const standardizeKeySystemOptions = (keySystem, keySystemOptions) => { 487 | if (typeof keySystemOptions === 'string') { 488 | keySystemOptions = { url: keySystemOptions }; 489 | } 490 | 491 | if (!keySystemOptions.url && keySystemOptions.licenseUri) { 492 | keySystemOptions.url = keySystemOptions.licenseUri; 493 | } 494 | 495 | if (!keySystemOptions.url && !keySystemOptions.getLicense) { 496 | throw new Error(`Missing url/licenseUri or getLicense in ${keySystem} keySystem configuration.`); 497 | } 498 | 499 | const isFairplay = isFairplayKeySystem(keySystem); 500 | 501 | if (isFairplay && keySystemOptions.certificateUri && !keySystemOptions.getCertificate) { 502 | keySystemOptions.getCertificate = defaultFairplayGetCertificate(keySystem, keySystemOptions); 503 | } 504 | 505 | if (isFairplay && !keySystemOptions.getCertificate) { 506 | throw new Error(`Missing getCertificate or certificateUri in ${keySystem} keySystem configuration.`); 507 | } 508 | 509 | if (isFairplay && !keySystemOptions.getContentId) { 510 | keySystemOptions.getContentId = defaultFairplayGetContentId; 511 | } 512 | 513 | if (keySystemOptions.url && !keySystemOptions.getLicense) { 514 | if (keySystem === 'com.microsoft.playready') { 515 | keySystemOptions.getLicense = defaultPlayreadyGetLicense(keySystem, keySystemOptions); 516 | } else if (isFairplay) { 517 | keySystemOptions.getLicense = defaultFairplayGetLicense(keySystem, keySystemOptions); 518 | } else { 519 | keySystemOptions.getLicense = defaultGetLicense(keySystem, keySystemOptions); 520 | } 521 | } 522 | 523 | return keySystemOptions; 524 | }; 525 | 526 | export const standard5July2016 = ({ 527 | player, 528 | video, 529 | initDataType, 530 | initData, 531 | keySystemAccess, 532 | options, 533 | removeSession, 534 | eventBus, 535 | emeError 536 | }) => { 537 | let keySystemPromise = Promise.resolve(); 538 | const keySystem = keySystemAccess.keySystem; 539 | let keySystemOptions; 540 | 541 | // try catch so that we return a promise rejection 542 | try { 543 | keySystemOptions = standardizeKeySystemOptions( 544 | keySystem, 545 | options.keySystems[keySystem] 546 | ); 547 | } catch (e) { 548 | return Promise.reject(e); 549 | } 550 | 551 | const contentId = keySystemOptions.getContentId ? 552 | keySystemOptions.getContentId(options, uint8ArrayToString(initData)) : null; 553 | 554 | if (typeof video.mediaKeysObject === 'undefined') { 555 | // Prevent entering this path again. 556 | video.mediaKeysObject = null; 557 | 558 | // Will store all initData until the MediaKeys is ready. 559 | video.pendingSessionData = []; 560 | 561 | let certificate; 562 | 563 | keySystemPromise = new Promise((resolve, reject) => { 564 | // save key system for adding sessions 565 | video.keySystem = keySystem; 566 | 567 | if (!keySystemOptions.getCertificate) { 568 | resolve(keySystemAccess); 569 | return; 570 | } 571 | 572 | keySystemOptions.getCertificate(options, (err, cert) => { 573 | if (err) { 574 | reject(err); 575 | return; 576 | } 577 | 578 | certificate = cert; 579 | 580 | resolve(); 581 | }); 582 | }).then(() => { 583 | return keySystemAccess.createMediaKeys(); 584 | }).then((createdMediaKeys) => { 585 | safeTriggerOnEventBus(eventBus, { 586 | type: 'keysystemaccesscomplete', 587 | mediaKeys: createdMediaKeys 588 | }); 589 | return addPendingSessions({ 590 | player, 591 | video, 592 | certificate, 593 | createdMediaKeys, 594 | emeError 595 | }); 596 | }).catch((err) => { 597 | const metadata = { 598 | errorType: EmeError.EMEFailedToCreateMediaKeys, 599 | keySystem 600 | }; 601 | 602 | emeError(err, metadata); 603 | // if we have a specific error message, use it, otherwise show a more 604 | // generic one 605 | if (err) { 606 | return Promise.reject(err); 607 | } 608 | return Promise.reject('Failed to create and initialize a MediaKeys object'); 609 | }); 610 | } 611 | 612 | return keySystemPromise.then(() => { 613 | // if key system has not been determined then addSession doesn't need getLicense 614 | const getLicense = video.keySystem ? 615 | promisifyGetLicense(keySystem, keySystemOptions.getLicense, eventBus) : null; 616 | 617 | return addSession({ 618 | player, 619 | video, 620 | initDataType, 621 | initData, 622 | options, 623 | getLicense, 624 | contentId, 625 | removeSession, 626 | eventBus, 627 | emeError 628 | }); 629 | }); 630 | }; 631 | -------------------------------------------------------------------------------- /src/fairplay.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The W3C Working Draft of 22 October 2013 seems to be the best match for 3 | * the ms-prefixed API. However, it should only be used as a guide; it is 4 | * doubtful the spec is 100% implemented as described. 5 | * 6 | * @see https://www.w3.org/TR/2013/WD-encrypted-media-20131022 7 | */ 8 | import videojs from 'video.js'; 9 | import window from 'global/window'; 10 | import {stringToUint16Array, uint16ArrayToString, getHostnameFromUri, mergeAndRemoveNull} from './utils'; 11 | import {httpResponseHandler} from './http-handler.js'; 12 | import EmeError from './consts/errors'; 13 | import { safeTriggerOnEventBus } from './eme.js'; 14 | 15 | export const LEGACY_FAIRPLAY_KEY_SYSTEM = 'com.apple.fps.1_0'; 16 | 17 | const concatInitDataIdAndCertificate = ({initData, id, cert}) => { 18 | if (typeof id === 'string') { 19 | id = stringToUint16Array(id); 20 | } 21 | 22 | // layout: 23 | // [initData] 24 | // [4 byte: idLength] 25 | // [idLength byte: id] 26 | // [4 byte:certLength] 27 | // [certLength byte: cert] 28 | let offset = 0; 29 | const buffer = new ArrayBuffer(initData.byteLength + 4 + id.byteLength + 4 + cert.byteLength); 30 | const dataView = new DataView(buffer); 31 | const initDataArray = new Uint8Array(buffer, offset, initData.byteLength); 32 | 33 | initDataArray.set(initData); 34 | offset += initData.byteLength; 35 | 36 | dataView.setUint32(offset, id.byteLength, true); 37 | offset += 4; 38 | 39 | const idArray = new Uint16Array(buffer, offset, id.length); 40 | 41 | idArray.set(id); 42 | offset += idArray.byteLength; 43 | 44 | dataView.setUint32(offset, cert.byteLength, true); 45 | offset += 4; 46 | 47 | const certArray = new Uint8Array(buffer, offset, cert.byteLength); 48 | 49 | certArray.set(cert); 50 | 51 | return new Uint8Array(buffer, 0, buffer.byteLength); 52 | }; 53 | 54 | const addKey = ({video, contentId, initData, cert, options, getLicense, eventBus, emeError}) => { 55 | return new Promise((resolve, reject) => { 56 | if (!video.webkitKeys) { 57 | try { 58 | video.webkitSetMediaKeys(new window.WebKitMediaKeys(LEGACY_FAIRPLAY_KEY_SYSTEM)); 59 | } catch (error) { 60 | const metadata = { 61 | errorType: EmeError.EMEFailedToCreateMediaKeys, 62 | keySystem: LEGACY_FAIRPLAY_KEY_SYSTEM 63 | }; 64 | 65 | emeError(error, metadata); 66 | reject('Could not create MediaKeys'); 67 | return; 68 | } 69 | } 70 | 71 | let keySession; 72 | 73 | try { 74 | keySession = video.webkitKeys.createSession( 75 | 'video/mp4', 76 | concatInitDataIdAndCertificate({id: contentId, initData, cert}) 77 | ); 78 | } catch (error) { 79 | const metadata = { 80 | errorType: EmeError.EMEFailedToCreateMediaKeySession, 81 | keySystem: LEGACY_FAIRPLAY_KEY_SYSTEM 82 | }; 83 | 84 | emeError(error, metadata); 85 | reject('Could not create key session'); 86 | return; 87 | } 88 | 89 | safeTriggerOnEventBus(eventBus, { 90 | type: 'keysessioncreated', 91 | keySession 92 | }); 93 | 94 | keySession.contentId = contentId; 95 | 96 | keySession.addEventListener('webkitkeymessage', (event) => { 97 | safeTriggerOnEventBus(eventBus, { 98 | type: 'keymessage', 99 | messageEvent: event 100 | }); 101 | getLicense(options, contentId, event.message, (err, license) => { 102 | if (eventBus) { 103 | 104 | safeTriggerOnEventBus(eventBus, { type: 'licenserequestattempted' }); 105 | } 106 | if (err) { 107 | const metadata = { 108 | errortype: EmeError.EMEFailedToGenerateLicenseRequest, 109 | keySystem: LEGACY_FAIRPLAY_KEY_SYSTEM 110 | }; 111 | 112 | emeError(err, metadata); 113 | reject(err); 114 | return; 115 | } 116 | 117 | keySession.update(new Uint8Array(license)); 118 | 119 | safeTriggerOnEventBus(eventBus, { 120 | type: 'keysessionupdated', 121 | keySession 122 | }); 123 | }); 124 | }); 125 | 126 | keySession.addEventListener('webkitkeyadded', () => { 127 | resolve(); 128 | }); 129 | 130 | // for testing purposes, adding webkitkeyerror must be the last item in this method 131 | keySession.addEventListener('webkitkeyerror', () => { 132 | const error = keySession.error; 133 | const metadata = { 134 | errorType: EmeError.EMEFailedToUpdateSessionWithReceivedLicenseKeys, 135 | keySystem: LEGACY_FAIRPLAY_KEY_SYSTEM 136 | }; 137 | 138 | emeError(error, metadata); 139 | reject(`KeySession error: code ${error.code}, systemCode ${error.systemCode}`); 140 | }); 141 | }); 142 | }; 143 | 144 | export const defaultGetCertificate = (keySystem, fairplayOptions) => { 145 | return (emeOptions, callback) => { 146 | const headers = mergeAndRemoveNull( 147 | emeOptions.emeHeaders, 148 | fairplayOptions.certificateHeaders 149 | ); 150 | 151 | videojs.xhr({ 152 | uri: fairplayOptions.certificateUri, 153 | responseType: 'arraybuffer', 154 | requestType: 'license', 155 | metadata: { keySystem }, 156 | headers 157 | }, httpResponseHandler((err, license) => { 158 | if (err) { 159 | callback(err); 160 | return; 161 | } 162 | 163 | // in this case, license is still the raw ArrayBuffer, 164 | // (we don't want httpResponseHandler to decode it) 165 | // convert it into Uint8Array as expected 166 | callback(null, new Uint8Array(license)); 167 | })); 168 | }; 169 | }; 170 | 171 | export const defaultGetContentId = (emeOptions, initDataString) => { 172 | return getHostnameFromUri(initDataString); 173 | }; 174 | 175 | export const defaultGetLicense = (keySystem, fairplayOptions) => { 176 | return (emeOptions, contentId, keyMessage, callback) => { 177 | const headers = mergeAndRemoveNull( 178 | {'Content-type': 'application/octet-stream'}, 179 | emeOptions.emeHeaders, 180 | fairplayOptions.licenseHeaders 181 | ); 182 | 183 | videojs.xhr({ 184 | uri: fairplayOptions.licenseUri || fairplayOptions.url, 185 | method: 'POST', 186 | responseType: 'arraybuffer', 187 | requestType: 'license', 188 | metadata: { keySystem, contentId }, 189 | body: keyMessage, 190 | headers 191 | }, httpResponseHandler(callback, true)); 192 | }; 193 | }; 194 | 195 | const fairplay = ({video, initData, options, eventBus, emeError}) => { 196 | const fairplayOptions = options.keySystems[LEGACY_FAIRPLAY_KEY_SYSTEM]; 197 | const getCertificate = fairplayOptions.getCertificate || 198 | defaultGetCertificate(LEGACY_FAIRPLAY_KEY_SYSTEM, fairplayOptions); 199 | const getContentId = fairplayOptions.getContentId || defaultGetContentId; 200 | const getLicense = fairplayOptions.getLicense || 201 | defaultGetLicense(LEGACY_FAIRPLAY_KEY_SYSTEM, fairplayOptions); 202 | 203 | return new Promise((resolve, reject) => { 204 | getCertificate(options, (err, cert) => { 205 | if (err) { 206 | const metadata = { 207 | errorType: EmeError.EMEFailedToSetServerCertificate, 208 | keySystem: LEGACY_FAIRPLAY_KEY_SYSTEM 209 | }; 210 | 211 | emeError(err, metadata); 212 | reject(err); 213 | return; 214 | } 215 | 216 | resolve(cert); 217 | }); 218 | }).then((cert) => { 219 | return addKey({ 220 | video, 221 | cert, 222 | initData, 223 | getLicense, 224 | options, 225 | contentId: getContentId(options, uint16ArrayToString(initData)), 226 | eventBus, 227 | emeError 228 | }); 229 | }); 230 | }; 231 | 232 | export default fairplay; 233 | -------------------------------------------------------------------------------- /src/http-handler.js: -------------------------------------------------------------------------------- 1 | import videojs from 'video.js'; 2 | 3 | let httpResponseHandler = videojs.xhr.httpHandler; 4 | 5 | // to make sure this doesn't break with older versions of Video.js, 6 | // do a super simple wrapper instead 7 | if (!httpResponseHandler) { 8 | httpResponseHandler = (callback, decodeResponseBody) => (err, response, responseBody) => { 9 | if (err) { 10 | callback(err); 11 | return; 12 | } 13 | 14 | // if the HTTP status code is 4xx or 5xx, the request also failed 15 | if (response.statusCode >= 400 && response.statusCode <= 599) { 16 | let cause = responseBody; 17 | 18 | if (decodeResponseBody) { 19 | cause = String.fromCharCode.apply(null, new Uint8Array(responseBody)); 20 | } 21 | 22 | callback({cause}); 23 | return; 24 | } 25 | 26 | // otherwise, request succeeded 27 | callback(null, responseBody); 28 | }; 29 | } 30 | export { httpResponseHandler }; 31 | -------------------------------------------------------------------------------- /src/ms-prefixed.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The W3C Working Draft of 22 October 2013 seems to be the best match for 3 | * the ms-prefixed API. However, it should only be used as a guide; it is 4 | * doubtful the spec is 100% implemented as described. 5 | * 6 | * @see https://www.w3.org/TR/2013/WD-encrypted-media-20131022 7 | * @see https://docs.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/compatibility/mt598601(v=vs.85) 8 | */ 9 | import window from 'global/window'; 10 | import { requestPlayreadyLicense } from './playready'; 11 | import { getMediaKeySystemConfigurations } from './utils'; 12 | import EmeError from './consts/errors'; 13 | import { safeTriggerOnEventBus } from './eme'; 14 | 15 | export const PLAYREADY_KEY_SYSTEM = 'com.microsoft.playready'; 16 | 17 | export const addKeyToSession = (options, session, event, eventBus, emeError) => { 18 | let playreadyOptions = options.keySystems[PLAYREADY_KEY_SYSTEM]; 19 | 20 | if (typeof playreadyOptions.getKey === 'function') { 21 | playreadyOptions.getKey(options, event.destinationURL, event.message.buffer, (err, key) => { 22 | if (err) { 23 | const metadata = { 24 | errorType: EmeError.EMEFailedToRequestMediaKeySystemAccess, 25 | config: getMediaKeySystemConfigurations(options.keySystems) 26 | }; 27 | 28 | emeError(err, metadata); 29 | safeTriggerOnEventBus(eventBus, { 30 | message: 'Unable to get key: ' + err, 31 | target: session, 32 | type: 'mskeyerror' 33 | }); 34 | return; 35 | } 36 | 37 | session.update(key); 38 | 39 | safeTriggerOnEventBus(eventBus, { 40 | type: 'keysessionupdated', 41 | keySession: session 42 | }); 43 | }); 44 | return; 45 | } 46 | 47 | if (typeof playreadyOptions === 'string') { 48 | playreadyOptions = {url: playreadyOptions}; 49 | } else if (typeof playreadyOptions === 'boolean') { 50 | playreadyOptions = {}; 51 | } 52 | 53 | if (!playreadyOptions.url) { 54 | playreadyOptions.url = event.destinationURL; 55 | } 56 | 57 | const callback = (err, responseBody) => { 58 | if (eventBus) { 59 | safeTriggerOnEventBus(eventBus, { type: 'licenserequestattempted' }); 60 | } 61 | 62 | if (err) { 63 | const metadata = { 64 | errorType: EmeError.EMEFailedToGenerateLicenseRequest, 65 | keySystem: PLAYREADY_KEY_SYSTEM 66 | }; 67 | 68 | emeError(err, metadata); 69 | safeTriggerOnEventBus(eventBus, { 70 | message: 'Unable to request key from url: ' + playreadyOptions.url, 71 | target: session, 72 | type: 'mskeyerror' 73 | }); 74 | return; 75 | } 76 | 77 | session.update(new Uint8Array(responseBody)); 78 | }; 79 | 80 | if (playreadyOptions.getLicense) { 81 | playreadyOptions.getLicense(options, event.message.buffer, callback); 82 | } else { 83 | requestPlayreadyLicense(PLAYREADY_KEY_SYSTEM, playreadyOptions, event.message.buffer, options, callback); 84 | } 85 | }; 86 | 87 | export const createSession = (video, initData, options, eventBus, emeError) => { 88 | // Note: invalid mime type passed here throws a NotSupportedError 89 | const session = video.msKeys.createSession('video/mp4', initData); 90 | 91 | if (!session) { 92 | const error = new Error('Could not create key session.'); 93 | const metadata = { 94 | errorType: EmeError.EMEFailedToCreateMediaKeySession, 95 | keySystem: PLAYREADY_KEY_SYSTEM 96 | }; 97 | 98 | emeError(error, metadata); 99 | throw error; 100 | } 101 | 102 | safeTriggerOnEventBus(eventBus, { 103 | type: 'keysessioncreated', 104 | keySession: session 105 | }); 106 | 107 | // Note that mskeymessage may not always be called for PlayReady: 108 | // 109 | // "If initData contains a PlayReady object that contains an OnDemand header, only a 110 | // keyAdded event is returned (as opposed to a keyMessage event as described in the 111 | // Encrypted Media Extension draft). Similarly, if initData contains a PlayReady object 112 | // that contains a key identifier in the hashed data storage (HDS), only a keyAdded 113 | // event is returned." 114 | // eslint-disable-next-line max-len 115 | // @see [PlayReady License Acquisition]{@link https://msdn.microsoft.com/en-us/library/dn468979.aspx} 116 | session.addEventListener('mskeymessage', (event) => { 117 | safeTriggerOnEventBus(eventBus, { 118 | type: 'keymessage', 119 | messageEvent: event 120 | }); 121 | addKeyToSession(options, session, event, eventBus, emeError); 122 | }); 123 | 124 | session.addEventListener('mskeyerror', (event) => { 125 | const metadata = { 126 | errorType: EmeError.EMEFailedToCreateMediaKeySession, 127 | keySystem: PLAYREADY_KEY_SYSTEM 128 | }; 129 | 130 | emeError(session.error, metadata); 131 | safeTriggerOnEventBus(eventBus, { 132 | message: 'Unexpected key error from key session with ' + 133 | `code: ${session.error.code} and systemCode: ${session.error.systemCode}`, 134 | target: session, 135 | type: 'mskeyerror' 136 | }); 137 | }); 138 | 139 | session.addEventListener('mskeyadded', () => { 140 | safeTriggerOnEventBus(eventBus, { 141 | target: session, 142 | type: 'mskeyadded' 143 | }); 144 | }); 145 | }; 146 | 147 | export default ({video, initData, options, eventBus, emeError}) => { 148 | // Although by the standard examples the presence of video.msKeys is checked first to 149 | // verify that we aren't trying to create a new session when one already exists, here 150 | // sessions are managed earlier (on the player.eme object), meaning that at this point 151 | // any existing keys should be cleaned up. 152 | // TODO: Will this break rotation? Is it safe? 153 | if (video.msKeys) { 154 | delete video.msKeys; 155 | } 156 | 157 | try { 158 | video.msSetMediaKeys(new window.MSMediaKeys(PLAYREADY_KEY_SYSTEM)); 159 | } catch (e) { 160 | const metadata = { 161 | errorType: EmeError.EMEFailedToCreateMediaKeys, 162 | keySystem: PLAYREADY_KEY_SYSTEM 163 | }; 164 | 165 | emeError(e, metadata); 166 | throw new Error('Unable to create media keys for PlayReady key system. ' + 167 | 'Error: ' + e.message); 168 | } 169 | 170 | createSession(video, initData, options, eventBus, emeError); 171 | }; 172 | -------------------------------------------------------------------------------- /src/playready.js: -------------------------------------------------------------------------------- 1 | import videojs from 'video.js'; 2 | import window from 'global/window'; 3 | import {mergeAndRemoveNull} from './utils'; 4 | import {httpResponseHandler} from './http-handler.js'; 5 | 6 | /** 7 | * Parses the EME key message XML to extract HTTP headers and the Challenge element to use 8 | * in the PlayReady license request. 9 | * 10 | * @param {ArrayBuffer} message key message from EME 11 | * @return {Object} an object containing headers and the message body to use in the 12 | * license request 13 | */ 14 | export const getMessageContents = (message) => { 15 | // TODO do we want to support UTF-8? 16 | const xmlString = String.fromCharCode.apply(null, new Uint16Array(message)); 17 | const xml = (new window.DOMParser()) 18 | .parseFromString(xmlString, 'application/xml'); 19 | const headersElement = xml.getElementsByTagName('HttpHeaders')[0]; 20 | let headers = {}; 21 | 22 | if (headersElement) { 23 | const headerNames = headersElement.getElementsByTagName('name'); 24 | const headerValues = headersElement.getElementsByTagName('value'); 25 | 26 | for (let i = 0; i < headerNames.length; i++) { 27 | headers[headerNames[i].childNodes[0].nodeValue] = 28 | headerValues[i].childNodes[0].nodeValue; 29 | } 30 | } 31 | 32 | const challengeElement = xml.getElementsByTagName('Challenge')[0]; 33 | let challenge; 34 | 35 | if (challengeElement) { 36 | challenge = window.atob(challengeElement.childNodes[0].nodeValue); 37 | } 38 | 39 | // If we failed to parse the xml the soap message might be encoded already. 40 | // set the message data as the challenge and add generic SOAP headers. 41 | if (xml.querySelector('parsererror')) { 42 | headers = { 43 | 'Content-Type': 'text/xml; charset=utf-8', 44 | 'SOAPAction': '"http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense"' 45 | }; 46 | challenge = message; 47 | } 48 | 49 | return { 50 | headers, 51 | message: challenge 52 | }; 53 | }; 54 | 55 | export const requestPlayreadyLicense = (keySystem, keySystemOptions, messageBuffer, emeOptions, callback) => { 56 | const messageContents = getMessageContents(messageBuffer); 57 | const message = messageContents.message; 58 | 59 | const headers = mergeAndRemoveNull( 60 | messageContents.headers, 61 | emeOptions.emeHeaders, 62 | keySystemOptions.licenseHeaders 63 | ); 64 | 65 | videojs.xhr({ 66 | uri: keySystemOptions.url, 67 | method: 'post', 68 | headers, 69 | body: message, 70 | responseType: 'arraybuffer', 71 | requestType: 'license', 72 | metadata: { keySystem } 73 | }, httpResponseHandler(callback, true)); 74 | }; 75 | -------------------------------------------------------------------------------- /src/plugin.js: -------------------------------------------------------------------------------- 1 | import videojs from 'video.js'; 2 | import window from 'global/window'; 3 | import { standard5July2016, getSupportedKeySystem } from './eme'; 4 | import { 5 | default as fairplay, 6 | LEGACY_FAIRPLAY_KEY_SYSTEM 7 | } from './fairplay'; 8 | import { 9 | default as msPrefixed, 10 | PLAYREADY_KEY_SYSTEM 11 | } from './ms-prefixed'; 12 | import {detectSupportedCDMs } from './cdm.js'; 13 | import { arrayBuffersEqual, arrayBufferFrom, merge, getMediaKeySystemConfigurations } from './utils'; 14 | import {version as VERSION} from '../package.json'; 15 | import EmeError from './consts/errors'; 16 | 17 | export const hasSession = (sessions, initData) => { 18 | for (let i = 0; i < sessions.length; i++) { 19 | // Other types of sessions may be in the sessions array that don't store the initData 20 | // (for instance, PlayReady sessions on IE11). 21 | if (!sessions[i].initData) { 22 | continue; 23 | } 24 | 25 | // initData should be an ArrayBuffer by the spec: 26 | // eslint-disable-next-line max-len 27 | // @see [Media Encrypted Event initData Spec]{@link https://www.w3.org/TR/encrypted-media/#mediaencryptedeventinit} 28 | // 29 | // However, on some browsers it may come back with a typed array view of the buffer. 30 | // This is the case for IE11, however, since IE11 sessions are handled differently 31 | // (following the msneedkey PlayReady path), this coversion may not be important. It 32 | // is safe though, and might be a good idea to retain in the short term (until we have 33 | // catalogued the full range of browsers and their implementations). 34 | const sessionBuffer = arrayBufferFrom(sessions[i].initData); 35 | const initDataBuffer = arrayBufferFrom(initData); 36 | 37 | if (arrayBuffersEqual(sessionBuffer, initDataBuffer)) { 38 | return true; 39 | } 40 | } 41 | 42 | return false; 43 | }; 44 | 45 | export const removeSession = (sessions, initData) => { 46 | for (let i = 0; i < sessions.length; i++) { 47 | if (sessions[i].initData === initData) { 48 | sessions.splice(i, 1); 49 | return; 50 | } 51 | } 52 | }; 53 | 54 | export function handleEncryptedEvent(player, event, options, sessions, eventBus, emeError) { 55 | if (!options || !options.keySystems) { 56 | // return silently since it may be handled by a different system 57 | return Promise.resolve(); 58 | } 59 | // Legacy fairplay is the keysystem 'com.apple.fps.1_0'. 60 | // If we are using this keysystem we want to use WebkitMediaKeys. 61 | // This can be initialized manually with initLegacyFairplay(). 62 | if (options.keySystems[LEGACY_FAIRPLAY_KEY_SYSTEM]) { 63 | videojs.log.debug('eme', `Ignoring \'encrypted\' event, using legacy fairplay keySystem ${LEGACY_FAIRPLAY_KEY_SYSTEM}`); 64 | return Promise.resolve(); 65 | } 66 | 67 | let initData = event.initData; 68 | 69 | return getSupportedKeySystem(options.keySystems).then((keySystemAccess) => { 70 | const keySystem = keySystemAccess.keySystem; 71 | 72 | // Use existing init data from options if provided 73 | if (options.keySystems[keySystem] && 74 | options.keySystems[keySystem].pssh) { 75 | initData = options.keySystems[keySystem].pssh; 76 | } 77 | 78 | // "Initialization Data must be a fixed value for a given set of stream(s) or media 79 | // data. It must only contain information related to the keys required to play a given 80 | // set of stream(s) or media data." 81 | // eslint-disable-next-line max-len 82 | // @see [Initialization Data Spec]{@link https://www.w3.org/TR/encrypted-media/#initialization-data} 83 | if (hasSession(sessions, initData) || !initData) { 84 | // TODO convert to videojs.log.debug and add back in 85 | // https://github.com/videojs/video.js/pull/4780 86 | // videojs.log('eme', 87 | // 'Already have a configured session for init data, ignoring event.'); 88 | return Promise.resolve(); 89 | } 90 | 91 | sessions.push({ initData }); 92 | return standard5July2016({ 93 | player, 94 | video: event.target, 95 | initDataType: event.initDataType, 96 | initData, 97 | keySystemAccess, 98 | options, 99 | removeSession: removeSession.bind(null, sessions), 100 | eventBus, 101 | emeError 102 | }); 103 | }).catch((error) => { 104 | const metadata = { 105 | errorType: EmeError.EMEFailedToRequestMediaKeySystemAccess, 106 | config: getMediaKeySystemConfigurations(options.keySystems) 107 | }; 108 | 109 | emeError(error, metadata); 110 | }); 111 | } 112 | 113 | export const handleWebKitNeedKeyEvent = (event, options, eventBus, emeError) => { 114 | if (!options.keySystems || !options.keySystems[LEGACY_FAIRPLAY_KEY_SYSTEM] || !event.initData) { 115 | // return silently since it may be handled by a different system 116 | return Promise.resolve(); 117 | } 118 | 119 | // From Apple's example Safari FairPlay integration code, webkitneedkey is not repeated 120 | // for the same content. Unless documentation is found to present the opposite, handle 121 | // all webkitneedkey events the same (even if they are repeated). 122 | 123 | return fairplay({ 124 | video: event.target, 125 | initData: event.initData, 126 | options, 127 | eventBus, 128 | emeError 129 | }); 130 | }; 131 | 132 | export const handleMsNeedKeyEvent = (event, options, sessions, eventBus, emeError) => { 133 | if (!options.keySystems || !options.keySystems[PLAYREADY_KEY_SYSTEM]) { 134 | // return silently since it may be handled by a different system 135 | return; 136 | } 137 | 138 | // "With PlayReady content protection, your Web app must handle the first needKey event, 139 | // but it must then ignore any other needKey event that occurs." 140 | // eslint-disable-next-line max-len 141 | // @see [PlayReady License Acquisition]{@link https://msdn.microsoft.com/en-us/library/dn468979.aspx} 142 | // 143 | // Usually (and as per the example in the link above) this is determined by checking for 144 | // the existence of video.msKeys. However, since the video element may be reused, it's 145 | // easier to directly manage the session. 146 | if (sessions.reduce((acc, session) => acc || session.playready, false)) { 147 | // TODO convert to videojs.log.debug and add back in 148 | // https://github.com/videojs/video.js/pull/4780 149 | // videojs.log('eme', 150 | // 'An \'msneedkey\' event was receieved earlier, ignoring event.'); 151 | return; 152 | } 153 | 154 | let initData = event.initData; 155 | 156 | // Use existing init data from options if provided 157 | if (options.keySystems[PLAYREADY_KEY_SYSTEM] && 158 | options.keySystems[PLAYREADY_KEY_SYSTEM].pssh) { 159 | initData = options.keySystems[PLAYREADY_KEY_SYSTEM].pssh; 160 | } 161 | 162 | if (!initData) { 163 | return; 164 | } 165 | 166 | sessions.push({ playready: true, initData }); 167 | 168 | msPrefixed({ 169 | video: event.target, 170 | initData, 171 | options, 172 | eventBus, 173 | emeError 174 | }); 175 | }; 176 | 177 | export const getOptions = (player) => { 178 | return merge(player.currentSource(), player.eme.options); 179 | }; 180 | 181 | /** 182 | * Configure a persistent sessions array and activeSrc property to ensure we properly 183 | * handle each independent source's events. Should be run on any encrypted or needkey 184 | * style event to ensure that the sessions reflect the active source. 185 | * 186 | * @function setupSessions 187 | * @param {Player} player 188 | */ 189 | export const setupSessions = (player) => { 190 | const src = player.src(); 191 | 192 | if (src !== player.eme.activeSrc) { 193 | player.eme.activeSrc = src; 194 | player.eme.sessions = []; 195 | } 196 | }; 197 | 198 | /** 199 | * Construct a simple function that can be used to dispatch EME errors on the 200 | * player directly, such as providing it to a `.catch()`. 201 | * 202 | * @function emeErrorHandler 203 | * @param {Player} player 204 | * @return {Function} 205 | */ 206 | export const emeErrorHandler = (player) => { 207 | return (objOrErr, metadata) => { 208 | const error = { 209 | // MEDIA_ERR_ENCRYPTED is code 5 210 | code: 5 211 | }; 212 | 213 | if (typeof objOrErr === 'string') { 214 | error.message = objOrErr; 215 | } else if (objOrErr) { 216 | if (objOrErr.message) { 217 | error.message = objOrErr.message; 218 | } 219 | if (objOrErr.cause && 220 | (objOrErr.cause.length || 221 | objOrErr.cause.byteLength)) { 222 | error.cause = objOrErr.cause; 223 | } 224 | if (objOrErr.keySystem) { 225 | error.keySystem = objOrErr.keySystem; 226 | } 227 | // pass along original error object. 228 | error.originalError = objOrErr; 229 | } 230 | 231 | if (metadata) { 232 | error.metadata = metadata; 233 | } 234 | 235 | player.error(error); 236 | }; 237 | }; 238 | 239 | /** 240 | * Function to invoke when the player is ready. 241 | * 242 | * This is a great place for your plugin to initialize itself. When this 243 | * function is called, the player will have its DOM and child components 244 | * in place. 245 | * 246 | * @function onPlayerReady 247 | * @param {Player} player 248 | * @param {Function} emeError 249 | */ 250 | const onPlayerReady = (player, emeError) => { 251 | if (player.$('.vjs-tech').tagName.toLowerCase() !== 'video') { 252 | return; 253 | } 254 | 255 | setupSessions(player); 256 | 257 | if (window.MediaKeys) { 258 | const sendMockEncryptedEvent = () => { 259 | const mockEncryptedEvent = { 260 | initDataType: 'cenc', 261 | initData: null, 262 | target: player.tech_.el_ 263 | }; 264 | 265 | setupSessions(player); 266 | handleEncryptedEvent(player, mockEncryptedEvent, getOptions(player), player.eme.sessions, player.tech_, emeError); 267 | }; 268 | 269 | if (videojs.browser.IS_FIREFOX) { 270 | // Unlike Chrome, Firefox doesn't receive an `encrypted` event on 271 | // replay and seek-back after content ends and `handleEncryptedEvent` is never called. 272 | // So a fake encrypted event is necessary here. 273 | 274 | let handled; 275 | 276 | player.on('ended', () =>{ 277 | handled = false; 278 | player.one(['seek', 'play'], (e) => { 279 | if (!handled && player.eme.sessions.length === 0) { 280 | sendMockEncryptedEvent(); 281 | handled = true; 282 | } 283 | }); 284 | }); 285 | player.on('play', () => { 286 | const options = player.eme.options; 287 | const limitRenewalsMaxPauseDuration = options.limitRenewalsMaxPauseDuration; 288 | 289 | if (player.eme.sessions.length === 0 && typeof limitRenewalsMaxPauseDuration === 'number') { 290 | handled = true; 291 | sendMockEncryptedEvent(); 292 | } 293 | }); 294 | } 295 | 296 | // Support EME 05 July 2016 297 | // Chrome 42+, Firefox 47+, Edge, Safari 12.1+ on macOS 10.14+ 298 | player.tech_.el_.addEventListener('encrypted', (event) => { 299 | videojs.log.debug('eme', 'Received an \'encrypted\' event'); 300 | setupSessions(player); 301 | handleEncryptedEvent(player, event, getOptions(player), player.eme.sessions, player.tech_, emeError); 302 | }); 303 | } else if (window.WebKitMediaKeys) { 304 | player.eme.initLegacyFairplay(); 305 | } else if (window.MSMediaKeys) { 306 | // IE11 Windows 8.1+ 307 | // Since IE11 doesn't support promises, we have to use a combination of 308 | // try/catch blocks and event handling to simulate promise rejection. 309 | // Functionally speaking, there should be no discernible difference between 310 | // the behavior of IE11 and those of other browsers. 311 | player.tech_.el_.addEventListener('msneedkey', (event) => { 312 | videojs.log.debug('eme', 'Received an \'msneedkey\' event'); 313 | setupSessions(player); 314 | try { 315 | handleMsNeedKeyEvent(event, getOptions(player), player.eme.sessions, player.tech_, emeError); 316 | } catch (error) { 317 | emeError(error); 318 | } 319 | }); 320 | const msKeyErrorCallback = (error) => { 321 | emeError(error); 322 | }; 323 | 324 | player.tech_.on('mskeyerror', msKeyErrorCallback); 325 | // TODO: refactor this plugin so it can use a plugin dispose 326 | player.on('dispose', () => { 327 | player.tech_.off('mskeyerror', msKeyErrorCallback); 328 | }); 329 | } 330 | }; 331 | 332 | /** 333 | * A video.js plugin. 334 | * 335 | * In the plugin function, the value of `this` is a video.js `Player` 336 | * instance. You cannot rely on the player being in a "ready" state here, 337 | * depending on how the plugin is invoked. This may or may not be important 338 | * to you; if not, remove the wait for "ready"! 339 | * 340 | * @function eme 341 | * @param {Object} [options={}] 342 | * An object of options left to the plugin author to define. 343 | */ 344 | const eme = function(options = {}) { 345 | const player = this; 346 | const emeError = emeErrorHandler(player); 347 | 348 | player.ready(() => onPlayerReady(player, emeError)); 349 | 350 | // Plugin API 351 | player.eme = { 352 | /** 353 | * For manual setup for eme listeners (for example: after player.reset call) 354 | * basically for any cases when player.tech.el is changed 355 | */ 356 | setupEmeListeners() { 357 | onPlayerReady(player, emeError); 358 | }, 359 | /** 360 | * Sets up MediaKeys on demand 361 | * Works around https://bugs.chromium.org/p/chromium/issues/detail?id=895449 362 | * 363 | * @function initializeMediaKeys 364 | * @param {Object} [emeOptions={}] 365 | * An object of eme plugin options. 366 | * @param {Function} [callback=function(){}] 367 | * @param {boolean} [suppressErrorIfPossible=false] 368 | */ 369 | initializeMediaKeys(emeOptions = {}, callback = function() {}, suppressErrorIfPossible = false) { 370 | // TODO: this should be refactored and renamed to be less tied 371 | // to encrypted events 372 | const mergedEmeOptions = merge( 373 | player.currentSource(), 374 | options, 375 | emeOptions 376 | ); 377 | 378 | // fake an encrypted event for handleEncryptedEvent 379 | const mockEncryptedEvent = { 380 | initDataType: 'cenc', 381 | initData: null, 382 | target: player.tech_.el_ 383 | }; 384 | 385 | setupSessions(player); 386 | 387 | if (window.MediaKeys) { 388 | handleEncryptedEvent(player, mockEncryptedEvent, mergedEmeOptions, player.eme.sessions, player.tech_, emeError) 389 | .then(() => callback()) 390 | .catch((error) => { 391 | callback(error); 392 | if (!suppressErrorIfPossible) { 393 | emeError(error); 394 | } 395 | }); 396 | } else if (window.MSMediaKeys) { 397 | const msKeyHandler = (event) => { 398 | player.tech_.off('mskeyadded', msKeyHandler); 399 | player.tech_.off('mskeyerror', msKeyHandler); 400 | if (event.type === 'mskeyerror') { 401 | callback(event.target.error); 402 | if (!suppressErrorIfPossible) { 403 | emeError(event.message); 404 | } 405 | } else { 406 | callback(); 407 | } 408 | }; 409 | 410 | player.tech_.one('mskeyadded', msKeyHandler); 411 | player.tech_.one('mskeyerror', msKeyHandler); 412 | try { 413 | handleMsNeedKeyEvent(mockEncryptedEvent, mergedEmeOptions, player.eme.sessions, player.tech_, emeError); 414 | } catch (error) { 415 | player.tech_.off('mskeyadded', msKeyHandler); 416 | player.tech_.off('mskeyerror', msKeyHandler); 417 | callback(error); 418 | if (!suppressErrorIfPossible) { 419 | emeError(error); 420 | } 421 | } 422 | } 423 | }, 424 | initLegacyFairplay() { 425 | const handleFn = (event) => { 426 | videojs.log.debug('eme', 'Received a \'webkitneedkey\' event'); 427 | // TODO it's possible that the video state must be cleared if reusing the same video 428 | // element between sources 429 | setupSessions(player); 430 | handleWebKitNeedKeyEvent(event, getOptions(player), player.tech_, emeError) 431 | .catch((error) => { 432 | emeError(error); 433 | }); 434 | }; 435 | 436 | const webkitNeedKeyEventHandler = (event) => { 437 | const firstWebkitneedkeyTimeout = getOptions(player).firstWebkitneedkeyTimeout || 1000; 438 | const src = player.src(); 439 | // on source change or first startup reset webkitneedkey options. 440 | 441 | player.eme.webkitneedkey_ = player.eme.webkitneedkey_ || {}; 442 | 443 | // if the source changed we need to handle the first event again. 444 | // track source changes internally. 445 | if (player.eme.webkitneedkey_.src !== src) { 446 | player.eme.webkitneedkey_ = { 447 | handledFirstEvent: false, 448 | src 449 | }; 450 | } 451 | // It's possible that at the start of playback a rendition switch 452 | // on a small player in safari's HLS implementation will cause 453 | // two webkitneedkey events to occur. We want to make sure to cancel 454 | // our first existing request if we get another within 1 second. This 455 | // prevents a non-fatal player error from showing up due to a 456 | // request failure. 457 | if (!player.eme.webkitneedkey_.handledFirstEvent) { 458 | // clear the old timeout so that a new one can be created 459 | // with the new rendition's event data 460 | player.clearTimeout(player.eme.webkitneedkey_.timeout); 461 | player.eme.webkitneedkey_.timeout = player.setTimeout(() => { 462 | player.eme.webkitneedkey_.handledFirstEvent = true; 463 | player.eme.webkitneedkey_.timeout = null; 464 | handleFn(event); 465 | }, firstWebkitneedkeyTimeout); 466 | // after we have a verified first request, we will request on 467 | // every other event like normal. 468 | } else { 469 | handleFn(event); 470 | } 471 | }; 472 | 473 | let videoElement = player.tech_.el_; 474 | 475 | // Support Safari EME with FairPlay 476 | // (also used in early Chrome or Chrome with EME disabled flag) 477 | videoElement.addEventListener('webkitneedkey', webkitNeedKeyEventHandler); 478 | 479 | const cleanupWebkitNeedKeyHandler = () => { 480 | // no need in auto-cleanup if manual clean is called 481 | player.off('dispose', cleanupWebkitNeedKeyHandler); 482 | // check for null, if manual cleanup is called multiple times for any reason 483 | if (videoElement !== null) { 484 | videoElement.removeEventListener('webkitneedkey', webkitNeedKeyEventHandler); 485 | } 486 | 487 | videoElement = null; 488 | }; 489 | 490 | // auto-cleanup: 491 | player.on('dispose', cleanupWebkitNeedKeyHandler); 492 | 493 | // returning for manual cleanup 494 | return cleanupWebkitNeedKeyHandler; 495 | }, 496 | detectSupportedCDMs, 497 | options 498 | }; 499 | }; 500 | 501 | // Register the plugin with video.js. 502 | videojs.registerPlugin('eme', eme); 503 | 504 | // contrib-eme specific error const 505 | eme.Error = EmeError; 506 | 507 | // Include the version number. 508 | eme.VERSION = VERSION; 509 | 510 | export default eme; 511 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import document from 'global/document'; 2 | import videojs from 'video.js'; 3 | import { getSupportedConfigurations } from './eme'; 4 | 5 | export const stringToUint16Array = (string) => { 6 | // 2 bytes for each char 7 | const buffer = new ArrayBuffer(string.length * 2); 8 | const array = new Uint16Array(buffer); 9 | 10 | for (let i = 0; i < string.length; i++) { 11 | array[i] = string.charCodeAt(i); 12 | } 13 | 14 | return array; 15 | }; 16 | 17 | export const uint8ArrayToString = (array) => { 18 | return String.fromCharCode.apply(null, new Uint8Array(array.buffer || array)); 19 | }; 20 | 21 | export const uint16ArrayToString = (array) => { 22 | return String.fromCharCode.apply(null, new Uint16Array(array.buffer || array)); 23 | }; 24 | 25 | export const getHostnameFromUri = (uri) => { 26 | const link = document.createElement('a'); 27 | 28 | link.href = uri; 29 | return link.hostname; 30 | }; 31 | 32 | export const arrayBuffersEqual = (arrayBuffer1, arrayBuffer2) => { 33 | if (arrayBuffer1 === arrayBuffer2) { 34 | return true; 35 | } 36 | 37 | if (arrayBuffer1.byteLength !== arrayBuffer2.byteLength) { 38 | return false; 39 | } 40 | 41 | const dataView1 = new DataView(arrayBuffer1); 42 | const dataView2 = new DataView(arrayBuffer2); 43 | 44 | for (let i = 0; i < dataView1.byteLength; i++) { 45 | if (dataView1.getUint8(i) !== dataView2.getUint8(i)) { 46 | return false; 47 | } 48 | } 49 | 50 | return true; 51 | }; 52 | 53 | export const arrayBufferFrom = (bufferOrTypedArray) => { 54 | if (bufferOrTypedArray instanceof Uint8Array || 55 | bufferOrTypedArray instanceof Uint16Array) { 56 | return bufferOrTypedArray.buffer; 57 | } 58 | 59 | return bufferOrTypedArray; 60 | }; 61 | 62 | // Normalize between Video.js 6/7 (videojs.mergeOptions) and 8 (videojs.obj.merge). 63 | export const merge = (...args) => { 64 | const context = videojs.obj || videojs; 65 | const fn = context.merge || context.mergeOptions; 66 | 67 | return fn.apply(context, args); 68 | }; 69 | 70 | export const mergeAndRemoveNull = (...args) => { 71 | const result = merge(...args); 72 | 73 | // Any header whose value is `null` will be removed. 74 | Object.keys(result).forEach(k => { 75 | if (result[k] === null) { 76 | delete result[k]; 77 | } 78 | }); 79 | 80 | return result; 81 | }; 82 | 83 | /** 84 | * Transforms the keySystems object into a MediaKeySystemConfiguration Object array. 85 | * 86 | * @param {Object} keySystems object from the options. 87 | * @return {Array} of MediaKeySystemConfiguration objects. 88 | */ 89 | export const getMediaKeySystemConfigurations = (keySystems) => { 90 | const config = []; 91 | 92 | Object.keys(keySystems).forEach((keySystem) => { 93 | const mediaKeySystemConfig = getSupportedConfigurations(keySystem, keySystems[keySystem])[0]; 94 | 95 | config.push(mediaKeySystemConfig); 96 | }); 97 | return config; 98 | }; 99 | -------------------------------------------------------------------------------- /test/cdm.test.js: -------------------------------------------------------------------------------- 1 | import QUnit from 'qunit'; 2 | import window from 'global/window'; 3 | import videojs from 'video.js'; 4 | import {detectSupportedCDMs } from '../src/cdm.js'; 5 | 6 | // `IS_CHROMIUM` and `IS_WINDOWS` are newer Video.js features, so add fallback just in case 7 | const IS_CHROMIUM = videojs.browser.IS_CHROMIUM || (/Chrome|CriOS/i).test(window.navigator.userAgent); 8 | const IS_WINDOWS = videojs.browser.IS_WINDOWS || (/Windows/i).test(window.navigator.userAgent); 9 | 10 | QUnit.module('videojs-contrib-eme CDM Module'); 11 | 12 | QUnit.skip('detectSupportedCDMs() returns a Promise', function(assert) { 13 | const promise = detectSupportedCDMs(); 14 | 15 | assert.ok(promise.then); 16 | }); 17 | 18 | // NOTE: This test is not future-proof. It verifies that the CDM detect function 19 | // works as expected given browser's *current* CDM support. If that support changes, 20 | // this test may need updating. 21 | QUnit.test('detectSupportedCDMs() promise resolves correctly on different browsers', function(assert) { 22 | const done = assert.async(); 23 | const promise = detectSupportedCDMs(); 24 | 25 | promise.then((result) => { 26 | // Currently, widevine and clearkey don't work in headless Chrome, so we can't verify cdm support in 27 | // the remote Video.js test environment. However, it can be verified if testing locally in a real browser. 28 | // Headless Chrome bug: https://bugs.chromium.org/p/chromium/issues/detail?id=788662 29 | if (videojs.browser.IS_CHROME) { 30 | assert.equal(result.fairplay, false, 'fairplay not supported in Chrome'); 31 | assert.equal(result.playready, false, 'playready not supported in Chrome'); 32 | 33 | // Uncomment if testing locally in actual browser 34 | // assert.equal(result.clearkey, true, 'clearkey is supported in Chrome'); 35 | // assert.equal(result.widevine, true, 'widevine is supported in Chrome'); 36 | } 37 | 38 | // Widevine requires a plugin in Ubuntu Firefox so it also does not work in the remote Video.js test environment 39 | if (videojs.browser.IS_FIREFOX) { 40 | assert.equal(result.fairplay, false, 'fairplay not supported in FF'); 41 | assert.equal(result.playready, false, 'playready not supported in FF'); 42 | assert.equal(result.clearkey, true, 'clearkey is supported in FF'); 43 | 44 | // Uncomment if testing locally in actual browser 45 | // assert.equal(result.widevine, true, 'widevine is supported in Chrome and FF'); 46 | } 47 | 48 | if (videojs.browser.IS_ANY_SAFARI) { 49 | assert.deepEqual(result, { 50 | fairplay: true, 51 | clearkey: true, 52 | playready: false, 53 | widevine: false 54 | }, 'fairplay support reported in Safari'); 55 | } 56 | 57 | if (videojs.browser.IS_EDGE && IS_CHROMIUM && !IS_WINDOWS) { 58 | assert.deepEqual(result, { 59 | fairplay: false, 60 | playready: false, 61 | widevine: true, 62 | clearkey: true 63 | }, 'widevine support reported in non-Windows Chromium Edge'); 64 | } 65 | 66 | if (videojs.browser.IS_EDGE && IS_CHROMIUM && IS_WINDOWS) { 67 | assert.deepEqual(result, { 68 | fairplay: false, 69 | playready: true, 70 | widevine: true, 71 | clearkey: true 72 | }, 'widevine and playready support reported in Windows Chromium Edge'); 73 | } 74 | 75 | done(); 76 | }).catch(done); 77 | }); 78 | -------------------------------------------------------------------------------- /test/fairplay.test.js: -------------------------------------------------------------------------------- 1 | import QUnit from 'qunit'; 2 | import { 3 | default as fairplay, 4 | defaultGetLicense, 5 | defaultGetCertificate, 6 | LEGACY_FAIRPLAY_KEY_SYSTEM 7 | } from '../src/fairplay'; 8 | import videojs from 'video.js'; 9 | import window from 'global/window'; 10 | import { getMockEventBus } from './utils'; 11 | 12 | QUnit.module('videojs-contrib-eme fairplay', { 13 | beforeEach() { 14 | this.origXhr = videojs.xhr; 15 | 16 | videojs.xhr = (params, callback) => { 17 | return callback(null, {statusCode: 200}, new Uint8Array([0, 1, 2, 3]).buffer); 18 | }; 19 | }, 20 | afterEach() { 21 | videojs.xhr = this.origXhr; 22 | } 23 | }); 24 | 25 | QUnit.test('lifecycle', function(assert) { 26 | assert.expect(23); 27 | 28 | const done = assert.async(); 29 | const initData = new Uint8Array([1, 2, 3, 4]).buffer; 30 | const callbacks = {}; 31 | const callCounts = { 32 | getCertificate: 0, 33 | getLicense: 0, 34 | updateKeySession: 0, 35 | createSession: 0, 36 | licenseRequestAttempts: 0 37 | }; 38 | 39 | const getCertificate = (emeOptions, callback) => { 40 | callCounts.getCertificate++; 41 | callbacks.getCertificate = callback; 42 | }; 43 | const getLicense = (emeOptions, contentId, keyMessage, callback) => { 44 | callCounts.getLicense++; 45 | callbacks.getLicense = callback; 46 | }; 47 | 48 | const options = { 49 | keySystems: { 50 | 'com.apple.fps.1_0': { 51 | getCertificate, 52 | getLicense, 53 | // not needed due to mocking 54 | getContentId: () => 'some content id' 55 | } 56 | } 57 | }; 58 | 59 | const eventBus = { 60 | trigger: (event) => { 61 | if (event.type === 'licenserequestattempted') { 62 | callCounts.licenseRequestAttempts++; 63 | } 64 | }, 65 | isDisposed: () => { 66 | return false; 67 | } 68 | }; 69 | 70 | // trap event listeners 71 | const keySessionEventListeners = {}; 72 | 73 | const updateKeySession = (key) => { 74 | callCounts.updateKeySession++; 75 | }; 76 | 77 | let onKeySessionCreated; 78 | 79 | const createSession = (type, concatenatedData) => { 80 | callCounts.createSession++; 81 | return { 82 | addEventListener: (name, callback) => { 83 | keySessionEventListeners[name] = callback; 84 | 85 | if (name === 'webkitkeyerror') { 86 | // Since we don't have a way of executing code at the end of addKey's promise, 87 | // we assume that adding the listener for webkitkeyerror is the last run code 88 | // within the promise. 89 | onKeySessionCreated(); 90 | } 91 | }, 92 | update: updateKeySession 93 | }; 94 | }; 95 | 96 | // mock webkitKeys to avoid browser specific calls and enable us to verify ordering 97 | const video = { 98 | webkitKeys: { 99 | createSession 100 | } 101 | }; 102 | 103 | fairplay({ video, initData, options, eventBus }) 104 | .then(() => { 105 | done(); 106 | }); 107 | 108 | // Step 1: getCertificate 109 | assert.equal(callCounts.getCertificate, 1, 'getCertificate has been called'); 110 | assert.equal(callCounts.createSession, 0, 'a key session has not been created'); 111 | assert.equal(callCounts.getLicense, 0, 'getLicense has not been called'); 112 | assert.equal(callCounts.updateKeySession, 0, 'updateKeySession has not been called'); 113 | assert.equal( 114 | callCounts.licenseRequestAttempts, 0, 115 | 'license request event not triggered (since no callback yet)' 116 | ); 117 | 118 | callbacks.getCertificate(null, new Uint16Array([4, 5, 6, 7]).buffer); 119 | 120 | onKeySessionCreated = () => { 121 | // Step 2: create a key session 122 | assert.equal(callCounts.getCertificate, 1, 'getCertificate has been called'); 123 | assert.equal(callCounts.createSession, 1, 'a key session has been created'); 124 | assert.equal(callCounts.getLicense, 0, 'getLicense has not been called'); 125 | assert.equal(callCounts.updateKeySession, 0, 'updateKeySession has not been called'); 126 | assert.equal( 127 | callCounts.licenseRequestAttempts, 0, 128 | 'license request event not triggered (since no callback yet)' 129 | ); 130 | 131 | assert.ok( 132 | keySessionEventListeners.webkitkeymessage, 133 | 'added an event listener for webkitkeymessage' 134 | ); 135 | assert.ok( 136 | keySessionEventListeners.webkitkeyadded, 137 | 'added an event listener for webkitkeyadded' 138 | ); 139 | assert.ok( 140 | keySessionEventListeners.webkitkeyerror, 141 | 'added an event listener for webkitkeyerror' 142 | ); 143 | 144 | keySessionEventListeners.webkitkeymessage({}); 145 | 146 | // Step 3: get the key on webkitkeymessage 147 | assert.equal(callCounts.getCertificate, 1, 'getCertificate has been called'); 148 | assert.equal(callCounts.createSession, 1, 'a key session has been created'); 149 | assert.equal(callCounts.getLicense, 1, 'getLicense has been called'); 150 | assert.equal(callCounts.updateKeySession, 0, 'updateKeySession has not been called'); 151 | assert.equal( 152 | callCounts.licenseRequestAttempts, 0, 153 | 'license request event not triggered (since no callback yet)' 154 | ); 155 | 156 | callbacks.getLicense(null, []); 157 | 158 | // Step 4: update the key session with the key 159 | assert.equal(callCounts.getCertificate, 1, 'getCertificate has been called'); 160 | assert.equal(callCounts.createSession, 1, 'a key session has been created'); 161 | assert.equal(callCounts.getLicense, 1, 'getLicense has been called'); 162 | assert.equal(callCounts.updateKeySession, 1, 'updateKeySession has been called'); 163 | assert.equal( 164 | callCounts.licenseRequestAttempts, 1, 165 | 'license request event triggered' 166 | ); 167 | 168 | keySessionEventListeners.webkitkeyadded(); 169 | }; 170 | }); 171 | 172 | QUnit.test('error in getCertificate rejects promise', function(assert) { 173 | const keySystems = {}; 174 | const done = assert.async(1); 175 | const emeError = (_, metadata) => { 176 | assert.equal(metadata.errorType, videojs.Error.EMEFailedToSetServerCertificate, 'errorType is expected value'); 177 | assert.equal(metadata.keySystem, LEGACY_FAIRPLAY_KEY_SYSTEM, 'keySystem is expected value'); 178 | }; 179 | 180 | keySystems[LEGACY_FAIRPLAY_KEY_SYSTEM] = { 181 | getCertificate: (options, callback) => { 182 | callback('error in getCertificate'); 183 | } 184 | }; 185 | 186 | fairplay({options: {keySystems}, eventBus: getMockEventBus(), emeError}).catch((err) => { 187 | assert.equal(err, 'error in getCertificate', 'message is good'); 188 | done(); 189 | }); 190 | 191 | }); 192 | 193 | QUnit.test('error in WebKitMediaKeys rejects promise', function(assert) { 194 | const keySystems = {}; 195 | const done = assert.async(1); 196 | const initData = new Uint8Array([1, 2, 3, 4]).buffer; 197 | const video = { 198 | webkitSetMediaKeys: () => {} 199 | }; 200 | const emeError = (_, metadata) => { 201 | assert.equal(metadata.errorType, videojs.Error.EMEFailedToCreateMediaKeys, 'errorType is expected value'); 202 | assert.equal(metadata.keySystem, LEGACY_FAIRPLAY_KEY_SYSTEM, 'keySystem is expected value'); 203 | }; 204 | 205 | window.WebKitMediaKeys = () => { 206 | throw new Error('unsupported keySystem'); 207 | }; 208 | 209 | keySystems[LEGACY_FAIRPLAY_KEY_SYSTEM] = {}; 210 | 211 | fairplay({ 212 | video, 213 | initData, 214 | options: {keySystems}, 215 | eventBus: getMockEventBus(), 216 | emeError 217 | }).catch(err => { 218 | assert.equal(err, 'Could not create MediaKeys', 'message is good'); 219 | done(); 220 | }); 221 | 222 | }); 223 | 224 | QUnit.test('error in webkitSetMediaKeys rejects promise', function(assert) { 225 | const keySystems = {}; 226 | const done = assert.async(1); 227 | const initData = new Uint8Array([1, 2, 3, 4]).buffer; 228 | const video = { 229 | webkitSetMediaKeys: () => { 230 | throw new Error('MediaKeys unusable'); 231 | } 232 | }; 233 | const emeError = (_, metadata) => { 234 | assert.equal(metadata.errorType, videojs.Error.EMEFailedToCreateMediaKeys, 'errorType is expected value'); 235 | assert.equal(metadata.keySystem, LEGACY_FAIRPLAY_KEY_SYSTEM, 'keySystem is expected value'); 236 | }; 237 | 238 | window.WebKitMediaKeys = function() {}; 239 | 240 | keySystems[LEGACY_FAIRPLAY_KEY_SYSTEM] = {}; 241 | 242 | fairplay({ 243 | video, 244 | initData, 245 | options: {keySystems}, 246 | eventBus: getMockEventBus(), 247 | emeError 248 | }).catch(err => { 249 | assert.equal(err, 'Could not create MediaKeys', 'message is good'); 250 | done(); 251 | }); 252 | 253 | }); 254 | 255 | QUnit.test('error in webkitKeys.createSession rejects promise', function(assert) { 256 | const keySystems = {}; 257 | const done = assert.async(1); 258 | const initData = new Uint8Array([1, 2, 3, 4]).buffer; 259 | const video = { 260 | webkitSetMediaKeys: () => { 261 | video.webkitKeys = { 262 | createSession: () => { 263 | throw new Error('invalid mimeType or initData'); 264 | } 265 | }; 266 | } 267 | }; 268 | const emeError = (_, metadata) => { 269 | assert.equal(metadata.errorType, videojs.Error.EMEFailedToCreateMediaKeySession, 'errorType is expected value'); 270 | assert.equal(metadata.keySystem, LEGACY_FAIRPLAY_KEY_SYSTEM, 'keySystem is expected value'); 271 | }; 272 | 273 | window.WebKitMediaKeys = function() {}; 274 | 275 | keySystems[LEGACY_FAIRPLAY_KEY_SYSTEM] = {}; 276 | 277 | fairplay({ 278 | video, 279 | initData, 280 | options: {keySystems}, 281 | eventBus: getMockEventBus(), 282 | emeError 283 | }).catch(err => { 284 | assert.equal( 285 | err, 'Could not create key session', 286 | 'message is good' 287 | ); 288 | done(); 289 | }); 290 | 291 | }); 292 | 293 | QUnit.test('error in getLicense rejects promise', function(assert) { 294 | const keySystems = {}; 295 | const done = assert.async(1); 296 | const initData = new Uint8Array([1, 2, 3, 4]).buffer; 297 | const video = { 298 | webkitSetMediaKeys: () => { 299 | video.webkitKeys = { 300 | createSession: () => { 301 | return { 302 | addEventListener: (event, callback) => { 303 | if (event === 'webkitkeymessage') { 304 | callback({message: 'whatever'}); 305 | } 306 | } 307 | }; 308 | } 309 | }; 310 | } 311 | }; 312 | const emeError = (_, metadata) => { 313 | assert.equal(metadata.keySystem, LEGACY_FAIRPLAY_KEY_SYSTEM, 'keySystem is expected value'); 314 | }; 315 | 316 | window.WebKitMediaKeys = function() {}; 317 | 318 | keySystems[LEGACY_FAIRPLAY_KEY_SYSTEM] = { 319 | getLicense: (options, contentId, message, callback) => { 320 | callback('error in getLicense'); 321 | } 322 | }; 323 | 324 | fairplay({ 325 | video, 326 | initData, 327 | options: {keySystems}, 328 | eventBus: getMockEventBus(), 329 | emeError 330 | }).catch(err => { 331 | assert.equal(err, 'error in getLicense', 'message is good'); 332 | done(); 333 | }); 334 | 335 | }); 336 | 337 | QUnit.test('keysessioncreated fired on key session created', function(assert) { 338 | const keySystems = {}; 339 | const done = assert.async(); 340 | const initData = new Uint8Array([1, 2, 3, 4]).buffer; 341 | let sessionCreated = false; 342 | const addEventListener = () => {}; 343 | const video = { 344 | webkitSetMediaKeys: () => { 345 | video.webkitKeys = { 346 | createSession: () => { 347 | sessionCreated = true; 348 | return { 349 | addEventListener 350 | }; 351 | } 352 | }; 353 | } 354 | }; 355 | const eventBus = { 356 | trigger: (event) => { 357 | if (event.type === 'keysessioncreated') { 358 | assert.ok(sessionCreated, 'keysessioncreated fired after session created'); 359 | assert.deepEqual(event.keySession, { addEventListener }, 'keySession payload passed with event'); 360 | done(); 361 | } 362 | }, 363 | isDisposed: () => { 364 | return false; 365 | } 366 | }; 367 | 368 | window.WebKitMediaKeys = function() {}; 369 | 370 | keySystems[LEGACY_FAIRPLAY_KEY_SYSTEM] = { 371 | licenseUri: 'some-url', 372 | certificateUri: 'some-other-url' 373 | }; 374 | 375 | fairplay({ 376 | video, 377 | initData, 378 | options: { keySystems }, 379 | eventBus 380 | }); 381 | }); 382 | 383 | QUnit.test('a webkitkeyerror rejects promise', function(assert) { 384 | let keySession; 385 | const keySystems = {}; 386 | const done = assert.async(1); 387 | const initData = new Uint8Array([1, 2, 3, 4]).buffer; 388 | const video = { 389 | webkitSetMediaKeys: () => { 390 | video.webkitKeys = { 391 | createSession: () => { 392 | return { 393 | addEventListener: (event, callback) => { 394 | if (event === 'webkitkeyerror') { 395 | callback('webkitkeyerror'); 396 | } 397 | }, 398 | error: { 399 | code: 0, 400 | systemCode: 1 401 | } 402 | }; 403 | } 404 | }; 405 | } 406 | }; 407 | const emeError = (_, metadata) => { 408 | assert.equal(metadata.errorType, videojs.Error.EMEFailedToUpdateSessionWithReceivedLicenseKeys, 'errorType is expected value'); 409 | assert.equal(metadata.keySystem, LEGACY_FAIRPLAY_KEY_SYSTEM, 'keySystem is expected value'); 410 | }; 411 | 412 | window.WebKitMediaKeys = function() {}; 413 | 414 | keySystems[LEGACY_FAIRPLAY_KEY_SYSTEM] = { 415 | getLicense: (options, contentId, message, callback) => { 416 | callback(null); 417 | keySession.trigger('webkitkeyerror'); 418 | } 419 | }; 420 | 421 | fairplay({ 422 | video, 423 | initData, 424 | options: {keySystems}, 425 | eventBus: getMockEventBus(), 426 | emeError 427 | }).catch(err => { 428 | assert.equal(err, 'KeySession error: code 0, systemCode 1', 'message is good'); 429 | done(); 430 | }); 431 | 432 | }); 433 | 434 | QUnit.test('emeHeaders sent with license and certificate requests', function(assert) { 435 | const origXhr = videojs.xhr; 436 | const emeOptions = { 437 | emeHeaders: { 438 | 'Some-Header': 'some-header-value' 439 | } 440 | }; 441 | const fairplayOptions = { 442 | licenseUri: 'some-url', 443 | certificateUri: 'some-other-url' 444 | }; 445 | const xhrCalls = []; 446 | 447 | videojs.xhr = (xhrOptions) => { 448 | xhrCalls.push(xhrOptions); 449 | }; 450 | 451 | const getLicense = defaultGetLicense('', fairplayOptions); 452 | const getCertificate = defaultGetCertificate('', fairplayOptions); 453 | 454 | getLicense(emeOptions, 'contentId'); 455 | getCertificate(emeOptions); 456 | 457 | assert.equal(xhrCalls.length, 2, 'made two XHR requests'); 458 | 459 | assert.deepEqual(xhrCalls[0], { 460 | uri: 'some-url', 461 | method: 'POST', 462 | responseType: 'arraybuffer', 463 | requestType: 'license', 464 | metadata: { keySystem: '', contentId: 'contentId' }, 465 | body: undefined, 466 | headers: { 467 | 'Content-type': 'application/octet-stream', 468 | 'Some-Header': 'some-header-value' 469 | } 470 | }, 'made license request with proper emeHeaders value'); 471 | 472 | assert.deepEqual(xhrCalls[1], { 473 | uri: 'some-other-url', 474 | responseType: 'arraybuffer', 475 | requestType: 'license', 476 | metadata: { keySystem: '' }, 477 | headers: { 478 | 'Some-Header': 'some-header-value' 479 | } 480 | }, 'made certificate request with proper emeHeaders value'); 481 | 482 | videojs.xhr = origXhr; 483 | }); 484 | 485 | QUnit.test('licenseHeaders and certificateHeaders properties override emeHeaders value', function(assert) { 486 | const origXhr = videojs.xhr; 487 | const emeOptions = { 488 | emeHeaders: { 489 | 'Some-Header': 'some-header-value' 490 | } 491 | }; 492 | const fairplayOptions = { 493 | licenseUri: 'some-url', 494 | certificateUri: 'some-other-url', 495 | licenseHeaders: { 496 | 'Some-Header': 'higher-priority-license-header' 497 | }, 498 | certificateHeaders: { 499 | 'Some-Header': 'higher-priority-cert-header' 500 | } 501 | }; 502 | const xhrCalls = []; 503 | 504 | videojs.xhr = (xhrOptions) => { 505 | xhrCalls.push(xhrOptions); 506 | }; 507 | 508 | const getLicense = defaultGetLicense('', fairplayOptions); 509 | const getCertificate = defaultGetCertificate('', fairplayOptions); 510 | 511 | getLicense(emeOptions, 'contentId'); 512 | getCertificate(emeOptions); 513 | 514 | assert.equal(xhrCalls.length, 2, 'made two XHR requests'); 515 | 516 | assert.deepEqual(xhrCalls[0], { 517 | uri: 'some-url', 518 | method: 'POST', 519 | responseType: 'arraybuffer', 520 | requestType: 'license', 521 | metadata: { keySystem: '', contentId: 'contentId' }, 522 | body: undefined, 523 | headers: { 524 | 'Content-type': 'application/octet-stream', 525 | 'Some-Header': 'higher-priority-license-header' 526 | } 527 | }, 'made license request with proper licenseHeaders value'); 528 | 529 | assert.deepEqual(xhrCalls[1], { 530 | uri: 'some-other-url', 531 | responseType: 'arraybuffer', 532 | requestType: 'license', 533 | metadata: { keySystem: '' }, 534 | headers: { 535 | 'Some-Header': 'higher-priority-cert-header' 536 | } 537 | }, 'made certificate request with proper certificateHeaders value'); 538 | 539 | videojs.xhr = origXhr; 540 | }); 541 | -------------------------------------------------------------------------------- /test/ms-prefixed.test.js: -------------------------------------------------------------------------------- 1 | import QUnit from 'qunit'; 2 | import videojs from 'video.js'; 3 | import window from 'global/window'; 4 | import { 5 | createMessageBuffer, 6 | challengeElement 7 | } from './playready-message'; 8 | import { 9 | PLAYREADY_KEY_SYSTEM, 10 | createSession, 11 | default as msPrefixed 12 | } from '../src/ms-prefixed'; 13 | import { 14 | stringToArrayBuffer, 15 | getMockEventBus 16 | } from './utils'; 17 | 18 | QUnit.module('videojs-contrib-eme ms-prefixed', { 19 | beforeEach() { 20 | this.origMSMediaKeys = window.MSMediaKeys; 21 | window.MSMediaKeys = function() {}; 22 | 23 | const session = new videojs.EventTarget(); 24 | 25 | session.keys = []; 26 | session.update = (key) => session.keys.push(key); 27 | 28 | // mock the video since the APIs won't be available on non IE11 browsers 29 | const video = { 30 | msSetMediaKeys: () => { 31 | video.msKeys = { 32 | createSession: () => this.session 33 | }; 34 | } 35 | }; 36 | 37 | this.session = session; 38 | this.video = video; 39 | }, 40 | afterEach() { 41 | window.MSMediaKeys = this.origMSMediaKeys; 42 | } 43 | }); 44 | 45 | QUnit.test('overwrites msKeys', function(assert) { 46 | const origMsKeys = {}; 47 | 48 | this.video.msKeys = origMsKeys; 49 | 50 | msPrefixed({ 51 | video: this.video, 52 | initData: '', 53 | options: { 54 | keySystems: { 55 | 'com.microsoft.playready': true 56 | } 57 | }, 58 | eventBus: getMockEventBus() 59 | }); 60 | 61 | assert.notEqual(this.video.msKeys, origMsKeys, 'overwrote msKeys'); 62 | }); 63 | 64 | QUnit.test('error thrown when creating keys bubbles up', function(assert) { 65 | const emeError = (_, metadata) => { 66 | assert.equal(metadata.errorType, videojs.Error.EMEFailedToCreateMediaKeys, 'errorType is expected value'); 67 | assert.equal(metadata.keySystem, PLAYREADY_KEY_SYSTEM, 'errorType is expected value'); 68 | }; 69 | 70 | window.MSMediaKeys = function() { 71 | throw new Error('error'); 72 | }; 73 | 74 | assert.throws( 75 | () => msPrefixed({video: this.video, emeError}), 76 | new Error('Unable to create media keys for PlayReady key system. Error: error'), 77 | 'error is thrown with proper message' 78 | ); 79 | }); 80 | 81 | QUnit.test('createSession throws unknown error', function(assert) { 82 | const video = { 83 | msSetMediaKeys: () => { 84 | video.msKeys = { 85 | createSession: () => { 86 | throw new Error('whatever'); 87 | } 88 | }; 89 | } 90 | }; 91 | 92 | assert.throws( 93 | () => msPrefixed({video}), 94 | new Error('whatever'), 95 | 'error is thrown with proper message' 96 | ); 97 | }); 98 | 99 | QUnit.test('throws error if session was not created', function(assert) { 100 | const video = { 101 | msSetMediaKeys: () => { 102 | video.msKeys = { 103 | createSession: () => null 104 | }; 105 | } 106 | }; 107 | const emeError = (_, metadata) => { 108 | assert.equal(metadata.errorType, videojs.Error.EMEFailedToCreateMediaKeySession, 'errorType is expected value'); 109 | assert.equal(metadata.keySystem, PLAYREADY_KEY_SYSTEM, 'errorType is expected value'); 110 | }; 111 | 112 | assert.throws( 113 | () => msPrefixed({video, emeError}), 114 | new Error('Could not create key session.'), 115 | 'error is thrown with proper message' 116 | ); 117 | }); 118 | 119 | QUnit.test('throws error on keysession mskeyerror event', function(assert) { 120 | let errorMessage; 121 | const emeError = (_, metadata) => { 122 | assert.equal(metadata.errorType, videojs.Error.EMEFailedToCreateMediaKeySession, 'errorType is expected value'); 123 | assert.equal(metadata.keySystem, PLAYREADY_KEY_SYSTEM, 'keySystem is expected value'); 124 | }; 125 | 126 | msPrefixed({ 127 | video: this.video, 128 | initData: '', 129 | options: { 130 | keySystems: { 131 | 'com.microsoft.playready': true 132 | } 133 | }, 134 | eventBus: { 135 | trigger: (event) => { 136 | errorMessage = typeof event === 'string' ? event : event.message; 137 | }, 138 | isDisposed: () => { 139 | return false; 140 | } 141 | }, 142 | emeError 143 | }); 144 | 145 | this.session.error = { 146 | code: 5, 147 | systemCode: 9 148 | }; 149 | 150 | this.session.trigger('mskeyerror'); 151 | 152 | assert.equal( 153 | errorMessage, 154 | 'Unexpected key error from key session with code: 5 and systemCode: 9', 155 | 'error is thrown with proper message' 156 | ); 157 | }); 158 | 159 | QUnit.test('calls getKey when provided on key message', function(assert) { 160 | let passedOptions = null; 161 | let passedDestinationURL = null; 162 | let passedBuffer = null; 163 | let passedCallback = null; 164 | let getKeyCallback = (callback) => { 165 | callback(null, 'a key'); 166 | }; 167 | let errorMessage; 168 | 169 | const emeOptions = { 170 | keySystems: { 171 | 'com.microsoft.playready': { 172 | getKey: (options, destinationURL, buffer, callback) => { 173 | passedOptions = options; 174 | passedDestinationURL = destinationURL; 175 | passedBuffer = buffer; 176 | passedCallback = callback; 177 | getKeyCallback(callback); 178 | } 179 | } 180 | } 181 | }; 182 | const emeError = (_, metadata) => { 183 | assert.equal(metadata.errorType, videojs.Error.EMEFailedToRequestMediaKeySystemAccess, 'errorType is expected value'); 184 | assert.deepEqual(metadata.config, [{}], 'keySystem is expected value'); 185 | }; 186 | 187 | msPrefixed({ 188 | video: this.video, 189 | initData: '', 190 | options: emeOptions, 191 | eventBus: { 192 | trigger: (event) => { 193 | errorMessage = typeof event === 'string' ? event : event.message; 194 | }, 195 | isDisposed: () => { 196 | return false; 197 | } 198 | }, 199 | emeError 200 | }); 201 | 202 | assert.notOk(passedOptions, 'getKey not called'); 203 | 204 | this.session.trigger({ 205 | type: 'mskeymessage', 206 | destinationURL: 'url', 207 | message: { 208 | buffer: 'buffer' 209 | } 210 | }); 211 | 212 | assert.equal(passedOptions, emeOptions, 'getKey called with options'); 213 | assert.equal(passedDestinationURL, 'url', 'getKey called with destinationURL'); 214 | assert.equal(passedBuffer, 'buffer', 'getKey called with buffer'); 215 | assert.equal(typeof passedCallback, 'function', 'getKey called with callback'); 216 | assert.equal(this.session.keys.length, 1, 'added key to session'); 217 | assert.equal(this.session.keys[0], 'a key', 'added correct key to session'); 218 | 219 | getKeyCallback = (callback) => { 220 | callback('an error', 'an errored key'); 221 | }; 222 | 223 | this.session.trigger({ 224 | type: 'mskeymessage', 225 | destinationURL: 'url', 226 | message: { 227 | buffer: 'buffer' 228 | } 229 | }); 230 | 231 | assert.equal( 232 | errorMessage, 233 | 'Unable to get key: an error', 234 | 'fires mskeyerror on eventBus when callback has an error' 235 | ); 236 | assert.equal(this.session.keys.length, 1, 'did not add a new key'); 237 | }); 238 | 239 | QUnit.test('makes request when nothing provided on key message', function(assert) { 240 | const origXhr = videojs.xhr; 241 | const xhrCalls = []; 242 | let errorMessage; 243 | const emeError = (_, metadata) => { 244 | assert.equal(metadata.errorType, videojs.Error.EMEFailedToGenerateLicenseRequest, 'errorType is expected value'); 245 | assert.equal(metadata.keySystem, PLAYREADY_KEY_SYSTEM, 'keySystem is expected value'); 246 | }; 247 | 248 | videojs.xhr = (config, callback) => xhrCalls.push({config, callback}); 249 | 250 | msPrefixed({ 251 | video: this.video, 252 | initData: '', 253 | options: { 254 | keySystems: { 255 | 'com.microsoft.playready': true 256 | } 257 | }, 258 | eventBus: { 259 | trigger: (event) => { 260 | if (typeof event === 'object' && event.type === 'mskeyerror') { 261 | errorMessage = event.message; 262 | } 263 | }, 264 | isDisposed: () => { 265 | return false; 266 | } 267 | }, 268 | emeError 269 | }); 270 | this.session.trigger({ 271 | type: 'mskeymessage', 272 | destinationURL: 'destination-url', 273 | message: { 274 | buffer: createMessageBuffer() 275 | } 276 | }); 277 | 278 | assert.equal(xhrCalls.length, 1, 'one xhr request'); 279 | assert.equal( 280 | xhrCalls[0].config.uri, 281 | 'destination-url', 282 | 'made request to destinationURL' 283 | ); 284 | assert.deepEqual( 285 | xhrCalls[0].config.headers, 286 | { 287 | 'Content-Type': 'text/xml; charset=utf-8', 288 | 'SOAPAction': '"http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense"' 289 | }, 290 | 'uses headers from message' 291 | ); 292 | assert.equal(xhrCalls[0].config.body, challengeElement, 'sends the challenge element'); 293 | assert.equal(xhrCalls[0].config.method, 'post', 'request is a post'); 294 | assert.equal( 295 | xhrCalls[0].config.responseType, 296 | 'arraybuffer', 297 | 'responseType is an arraybuffer' 298 | ); 299 | 300 | const response = { 301 | body: stringToArrayBuffer('key value') 302 | }; 303 | 304 | xhrCalls[0].callback('an error', response, response.body); 305 | 306 | assert.equal( 307 | errorMessage, 308 | 'Unable to request key from url: destination-url', 309 | 'triggers mskeyerror on event bus when callback has an error' 310 | ); 311 | assert.equal(this.session.keys.length, 0, 'no key added to session'); 312 | 313 | xhrCalls[0].callback(null, response, response.body); 314 | 315 | assert.equal(this.session.keys.length, 1, 'key added to session'); 316 | assert.deepEqual( 317 | this.session.keys[0], 318 | new Uint8Array(response.body), 319 | 'correct key added to session' 320 | ); 321 | 322 | videojs.xhr = origXhr; 323 | }); 324 | 325 | QUnit.test( 326 | 'makes request on key message when empty object provided in options', 327 | function(assert) { 328 | const origXhr = videojs.xhr; 329 | const xhrCalls = []; 330 | 331 | videojs.xhr = (config, callback) => xhrCalls.push({config, callback}); 332 | 333 | msPrefixed({ 334 | video: this.video, 335 | initData: '', 336 | options: { 337 | keySystems: { 338 | 'com.microsoft.playready': {} 339 | } 340 | }, 341 | eventBus: getMockEventBus() 342 | }); 343 | this.session.trigger({ 344 | type: 'mskeymessage', 345 | destinationURL: 'destination-url', 346 | message: { 347 | buffer: createMessageBuffer() 348 | } 349 | }); 350 | 351 | assert.equal(xhrCalls.length, 1, 'one xhr request'); 352 | assert.equal( 353 | xhrCalls[0].config.uri, 354 | 'destination-url', 355 | 'made request to destinationURL' 356 | ); 357 | assert.deepEqual( 358 | xhrCalls[0].config.headers, 359 | { 360 | 'Content-Type': 'text/xml; charset=utf-8', 361 | 'SOAPAction': '"http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense"' 362 | }, 363 | 'uses headers from message' 364 | ); 365 | assert.equal(xhrCalls[0].config.body, challengeElement, 'sends the challenge element'); 366 | assert.equal(xhrCalls[0].config.method, 'post', 'request is a post'); 367 | assert.equal( 368 | xhrCalls[0].config.responseType, 369 | 'arraybuffer', 370 | 'responseType is an arraybuffer' 371 | ); 372 | 373 | videojs.xhr = origXhr; 374 | } 375 | ); 376 | 377 | QUnit.test('makes request with provided url string on key message', function(assert) { 378 | const origXhr = videojs.xhr; 379 | const xhrCalls = []; 380 | let errorMessage; 381 | const emeError = (_, metadata) => { 382 | assert.equal(metadata.errorType, videojs.Error.EMEFailedToGenerateLicenseRequest, 'errorType is expected value'); 383 | assert.equal(metadata.keySystem, PLAYREADY_KEY_SYSTEM, 'keySystem is expected value'); 384 | }; 385 | 386 | videojs.xhr = (config, callback) => xhrCalls.push({config, callback}); 387 | 388 | msPrefixed({ 389 | video: this.video, 390 | initData: '', 391 | options: { 392 | keySystems: { 393 | 'com.microsoft.playready': 'provided-url' 394 | } 395 | }, 396 | eventBus: { 397 | trigger: (event) => { 398 | if (typeof event === 'object' && event.type === 'mskeyerror') { 399 | errorMessage = event.message; 400 | } 401 | }, 402 | isDisposed: () => { 403 | return false; 404 | } 405 | }, 406 | emeError 407 | }); 408 | this.session.trigger({ 409 | type: 'mskeymessage', 410 | destinationURL: 'destination-url', 411 | message: { 412 | buffer: createMessageBuffer([{ 413 | name: 'Content-Type', 414 | value: 'text/xml; charset=utf-8' 415 | }, { 416 | name: 'SOAPAction', 417 | value: '"http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense"' 418 | }]) 419 | } 420 | }); 421 | 422 | assert.equal(xhrCalls.length, 1, 'one xhr request'); 423 | assert.equal( 424 | xhrCalls[0].config.uri, 425 | 'provided-url', 426 | 'made request to provided-url' 427 | ); 428 | assert.deepEqual( 429 | xhrCalls[0].config.headers, 430 | { 431 | 'Content-Type': 'text/xml; charset=utf-8', 432 | 'SOAPAction': '"http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense"' 433 | }, 434 | 'uses headers from message' 435 | ); 436 | assert.equal(xhrCalls[0].config.body, challengeElement, 'sends the challenge element'); 437 | assert.equal(xhrCalls[0].config.method, 'post', 'request is a post'); 438 | assert.equal( 439 | xhrCalls[0].config.responseType, 440 | 'arraybuffer', 441 | 'responseType is an arraybuffer' 442 | ); 443 | 444 | const response = { 445 | body: stringToArrayBuffer('key value') 446 | }; 447 | 448 | xhrCalls[0].callback('an error', response, response.body); 449 | 450 | assert.equal( 451 | errorMessage, 452 | 'Unable to request key from url: provided-url', 453 | 'triggers mskeyerror on event bus when callback has an error' 454 | ); 455 | assert.equal(this.session.keys.length, 0, 'no key added to session'); 456 | 457 | xhrCalls[0].callback(null, response, response.body); 458 | 459 | assert.equal(this.session.keys.length, 1, 'key added to session'); 460 | assert.deepEqual( 461 | this.session.keys[0], 462 | new Uint8Array(response.body), 463 | 'correct key added to session' 464 | ); 465 | 466 | videojs.xhr = origXhr; 467 | }); 468 | 469 | QUnit.test('makes request with provided url on key message', function(assert) { 470 | const origXhr = videojs.xhr; 471 | const xhrCalls = []; 472 | const callCounts = { 473 | licenseRequestAttempts: 0 474 | }; 475 | let errorMessage; 476 | const emeError = (_, metadata) => { 477 | assert.equal(metadata.errorType, videojs.Error.EMEFailedToGenerateLicenseRequest, 'errorType is expected value'); 478 | assert.equal(metadata.keySystem, PLAYREADY_KEY_SYSTEM, 'keySystem is expected value'); 479 | }; 480 | 481 | videojs.xhr = (config, callback) => xhrCalls.push({config, callback}); 482 | 483 | msPrefixed({ 484 | video: this.video, 485 | initData: '', 486 | options: { 487 | keySystems: { 488 | 'com.microsoft.playready': { 489 | url: 'provided-url' 490 | } 491 | } 492 | }, 493 | eventBus: { 494 | trigger: (event) => { 495 | if (event.type === 'licenserequestattempted') { 496 | callCounts.licenseRequestAttempts++; 497 | } else if (typeof event === 'object' && event.type === 'mskeyerror') { 498 | errorMessage = event.message; 499 | } 500 | }, 501 | isDisposed: () => { 502 | return false; 503 | } 504 | }, 505 | emeError 506 | }); 507 | this.session.trigger({ 508 | type: 'mskeymessage', 509 | destinationURL: 'destination-url', 510 | message: { 511 | buffer: createMessageBuffer([{ 512 | name: 'Content-Type', 513 | value: 'text/xml; charset=utf-8' 514 | }, { 515 | name: 'SOAPAction', 516 | value: '"http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense"' 517 | }]) 518 | } 519 | }); 520 | 521 | assert.equal(xhrCalls.length, 1, 'one xhr request'); 522 | assert.equal( 523 | xhrCalls[0].config.uri, 524 | 'provided-url', 525 | 'made request to provided-url' 526 | ); 527 | assert.deepEqual( 528 | xhrCalls[0].config.headers, 529 | { 530 | 'Content-Type': 'text/xml; charset=utf-8', 531 | 'SOAPAction': '"http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense"' 532 | }, 533 | 'uses headers from message' 534 | ); 535 | assert.equal(xhrCalls[0].config.body, challengeElement, 'sends the challenge element'); 536 | assert.equal(xhrCalls[0].config.method, 'post', 'request is a post'); 537 | assert.equal( 538 | xhrCalls[0].config.responseType, 539 | 'arraybuffer', 540 | 'responseType is an arraybuffer' 541 | ); 542 | assert.equal( 543 | callCounts.licenseRequestAttempts, 0, 544 | 'license request event not triggered (since no callback yet)' 545 | ); 546 | 547 | const response = { 548 | body: stringToArrayBuffer('key value') 549 | }; 550 | 551 | xhrCalls[0].callback('an error', response, response.body); 552 | 553 | assert.equal(callCounts.licenseRequestAttempts, 1, 'license request event triggered'); 554 | assert.equal( 555 | errorMessage, 556 | 'Unable to request key from url: provided-url', 557 | 'triggers mskeyerror on event bus when callback has an error' 558 | ); 559 | assert.equal(this.session.keys.length, 0, 'no key added to session'); 560 | 561 | xhrCalls[0].callback(null, response, response.body); 562 | 563 | assert.equal( 564 | callCounts.licenseRequestAttempts, 2, 565 | 'second license request event triggered' 566 | ); 567 | assert.equal(this.session.keys.length, 1, 'key added to session'); 568 | assert.deepEqual( 569 | this.session.keys[0], 570 | new Uint8Array(response.body), 571 | 'correct key added to session' 572 | ); 573 | 574 | videojs.xhr = origXhr; 575 | }); 576 | 577 | QUnit.test('will use a custom getLicense method if one is provided', function(assert) { 578 | let callCount = 0; 579 | 580 | msPrefixed({ 581 | video: this.video, 582 | initData: '', 583 | options: { 584 | keySystems: { 585 | 'com.microsoft.playready': { 586 | getLicense() { 587 | callCount++; 588 | } 589 | } 590 | } 591 | }, 592 | eventBus: getMockEventBus() 593 | }); 594 | 595 | const buffer = createMessageBuffer([{ 596 | name: 'Content-Type', 597 | value: 'text/xml; charset=utf-8' 598 | }, { 599 | name: 'SOAPAction', 600 | value: '"http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense"' 601 | }]); 602 | 603 | this.session.trigger({ 604 | type: 'mskeymessage', 605 | destinationURL: 'destination-url', 606 | message: {buffer} 607 | }); 608 | 609 | assert.equal(callCount, 1, 'getLicense was called'); 610 | }); 611 | 612 | QUnit.test('createSession triggers keysessioncreated', function(assert) { 613 | const addEventListener = () => {}; 614 | const video = { 615 | msKeys: { 616 | createSession: () => { 617 | return { 618 | addEventListener 619 | }; 620 | } 621 | } 622 | }; 623 | const eventBus = getMockEventBus(); 624 | 625 | createSession(video, '', {}, eventBus); 626 | 627 | assert.equal(eventBus.calls.length, 1, 'one event triggered'); 628 | assert.equal( 629 | eventBus.calls[0].type, 630 | 'keysessioncreated', 631 | 'triggered keysessioncreated event' 632 | ); 633 | assert.deepEqual(eventBus.calls[0].keySession, { addEventListener }, 'keysessioncreated payload'); 634 | }); 635 | -------------------------------------------------------------------------------- /test/playready-message.js: -------------------------------------------------------------------------------- 1 | import window from 'global/window'; 2 | import { stringToArrayBuffer } from './utils'; 3 | 4 | export const license = 'this is a license'; 5 | 6 | export const challengeElement = ` 7 | 8 | 12 | 13 | 14 | 15 | 16 | 17 | 1 18 | 19 | ${license} 20 | 21 | 22 | 23 | 24 | 25 | 26 | `; 27 | 28 | const defaultHeaders = [{ 29 | name: 'Content-Type', 30 | value: 'text/xml; charset=utf-8' 31 | }, { 32 | name: 'SOAPAction', 33 | value: '"http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense"' 34 | }]; 35 | 36 | const createHeaders = (headers) => { 37 | return headers.reduce((acc, header) => { 38 | return acc + ` 39 | 40 | ${header.name} 41 | ${header.value} 42 | 43 | `; 44 | }, ''); 45 | }; 46 | 47 | export const createMessageBuffer = (headers) => { 48 | headers = headers || defaultHeaders; 49 | 50 | // can't use TextEncoder because Safari doesn't support it 51 | return stringToArrayBuffer(` 52 | 53 | 54 | ${window.btoa(challengeElement)} 55 | ${createHeaders(headers)} 56 | 57 | `); 58 | }; 59 | 60 | export const unwrappedPlayreadyMessage = ` 61 | 62 | 63 | 64 | 65 | 66 | 67 | 116AESCTRU24lieXb6USvujjSyhfRdg==TKWDEqady2g=https://foo.bar.license4.2.0.5545ioydTlK2p0WXkWklprR5Hw==13Ef/RUojT3U6Ct2jqTCChbA==68U9WysleTindM/gVQyExDdw==1706149441 WMRMServerbarfoobarfoofoocipherbarcipher 68 | 69 | 70 | 71 | 72 | 73 | 74 | FL7P8/ITc+xFvUeoyMRq2JnNbJuhhKINsXtdDuM1Y78= 75 | 76 | 77 | Ocy3UTUu52QI0MIzdftANLQgJM3SsP6E2XvPlKYzQBtvscJbm/uTi38zrfY2RBU3FJZLtcj0O72lb5Mq5/CNJA== 78 | 79 | 80 | 81 | nxbw6pwjF4fF5sEqM23KU54ifXrRvejWK5GVdjdzCMY3dvjdp7Ho5h5YiZ34xOSAUHJsZwa4DW+P6XFIDauDzg== 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | `; 92 | -------------------------------------------------------------------------------- /test/playready.test.js: -------------------------------------------------------------------------------- 1 | import QUnit from 'qunit'; 2 | import { 3 | getMessageContents, 4 | requestPlayreadyLicense 5 | } from '../src/playready'; 6 | import { 7 | createMessageBuffer, 8 | challengeElement, 9 | unwrappedPlayreadyMessage 10 | } from './playready-message'; 11 | import videojs from 'video.js'; 12 | 13 | QUnit.module('playready'); 14 | 15 | QUnit.test('getMessageContents parses message contents', function(assert) { 16 | const {headers, message} = getMessageContents(createMessageBuffer()); 17 | 18 | assert.deepEqual( 19 | headers, 20 | { 21 | 'Content-Type': 'text/xml; charset=utf-8', 22 | 'SOAPAction': '"http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense"' 23 | }, 24 | 'parses headers' 25 | ); 26 | assert.deepEqual(message, challengeElement, 'parses challenge element'); 27 | }); 28 | 29 | QUnit.test('getMessageContents parses utf-8 contents', function(assert) { 30 | const encoder = new TextEncoder(); 31 | const encodedMessageData = encoder.encode(unwrappedPlayreadyMessage); 32 | const {headers, message} = getMessageContents(encodedMessageData); 33 | 34 | assert.deepEqual( 35 | headers, 36 | { 37 | 'Content-Type': 'text/xml; charset=utf-8', 38 | 'SOAPAction': '"http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense"' 39 | }, 40 | 'parses headers' 41 | ); 42 | assert.deepEqual(message, encodedMessageData, 'parses challenge element'); 43 | }); 44 | 45 | QUnit.test('emeHeaders sent with license requests', function(assert) { 46 | const origXhr = videojs.xhr; 47 | const emeOptions = { 48 | emeHeaders: { 49 | 'Some-Header': 'some-header-value' 50 | } 51 | }; 52 | const keySystemOptions = { 53 | url: 'some-url', 54 | licenseHeaders: {} 55 | }; 56 | const xhrCalls = []; 57 | 58 | videojs.xhr = (xhrOptions) => { 59 | xhrCalls.push(xhrOptions); 60 | }; 61 | 62 | requestPlayreadyLicense('com.microsoft.playready', keySystemOptions, createMessageBuffer(), emeOptions); 63 | 64 | assert.equal(xhrCalls.length, 1, 'made one XHR'); 65 | assert.deepEqual(xhrCalls[0], { 66 | uri: 'some-url', 67 | method: 'post', 68 | headers: { 69 | 'Content-Type': 'text/xml; charset=utf-8', 70 | 'SOAPAction': '"http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense"', 71 | 'Some-Header': 'some-header-value' 72 | }, 73 | body: challengeElement, 74 | responseType: 'arraybuffer', 75 | requestType: 'license', 76 | metadata: { keySystem: 'com.microsoft.playready' } 77 | }, 'license request sent with correct headers'); 78 | 79 | videojs.xhr = origXhr; 80 | }); 81 | 82 | QUnit.test('licenseHeaders property overrides emeHeaders', function(assert) { 83 | const origXhr = videojs.xhr; 84 | const emeOptions = { 85 | emeHeaders: { 86 | 'Some-Header': 'some-header-value' 87 | } 88 | }; 89 | const keySystemOptions = { 90 | url: 'some-url', 91 | licenseHeaders: { 92 | 'Some-Header': 'priority-header-value' 93 | } 94 | }; 95 | const xhrCalls = []; 96 | 97 | videojs.xhr = (xhrOptions) => { 98 | xhrCalls.push(xhrOptions); 99 | }; 100 | 101 | requestPlayreadyLicense('com.microsoft.playready', keySystemOptions, createMessageBuffer(), emeOptions); 102 | 103 | assert.equal(xhrCalls.length, 1, 'made one XHR'); 104 | assert.deepEqual(xhrCalls[0], { 105 | uri: 'some-url', 106 | method: 'post', 107 | headers: { 108 | 'Content-Type': 'text/xml; charset=utf-8', 109 | 'SOAPAction': '"http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense"', 110 | 'Some-Header': 'priority-header-value' 111 | }, 112 | body: challengeElement, 113 | responseType: 'arraybuffer', 114 | requestType: 'license', 115 | metadata: { keySystem: 'com.microsoft.playready' } 116 | }, 'license request sent with correct headers'); 117 | 118 | videojs.xhr = origXhr; 119 | }); 120 | -------------------------------------------------------------------------------- /test/plugin.test.js: -------------------------------------------------------------------------------- 1 | import document from 'global/document'; 2 | 3 | import QUnit from 'qunit'; 4 | import sinon from 'sinon'; 5 | import videojs from 'video.js'; 6 | import window from 'global/window'; 7 | import * as plug from '../src/plugin'; 8 | import { 9 | default as plugin, 10 | hasSession, 11 | setupSessions, 12 | handleEncryptedEvent, 13 | handleMsNeedKeyEvent, 14 | handleWebKitNeedKeyEvent, 15 | getOptions, 16 | removeSession, 17 | emeErrorHandler 18 | } from '../src/plugin'; 19 | import { 20 | getMockEventBus 21 | } from './utils'; 22 | 23 | const Player = videojs.getComponent('Player'); 24 | 25 | function noop() {} 26 | 27 | QUnit.test('the environment is sane', function(assert) { 28 | assert.strictEqual(typeof Array.isArray, 'function', 'es5 exists'); 29 | assert.strictEqual(typeof sinon, 'object', 'sinon exists'); 30 | assert.strictEqual(typeof videojs, 'function', 'videojs exists'); 31 | assert.strictEqual(typeof plugin, 'function', 'plugin is a function'); 32 | }); 33 | 34 | QUnit.module('videojs-contrib-eme', { 35 | beforeEach() { 36 | // Mock the environment's timers because certain things - particularly 37 | // player readiness - are asynchronous in video.js 5. This MUST come 38 | // before any player is created; otherwise, timers could get created 39 | // with the actual timer methods! 40 | this.clock = sinon.useFakeTimers(); 41 | 42 | this.fixture = document.getElementById('qunit-fixture'); 43 | this.video = document.createElement('video'); 44 | this.fixture.appendChild(this.video); 45 | this.player = videojs(this.video); 46 | 47 | this.origRequestMediaKeySystemAccess = window.navigator.requestMediaKeySystemAccess; 48 | 49 | window.navigator.requestMediaKeySystemAccess = (keySystem, options) => { 50 | return Promise.resolve({ 51 | keySystem: 'org.w3.clearkey', 52 | createMediaKeys: () => { 53 | return { 54 | createSession: () => new videojs.EventTarget() 55 | }; 56 | } 57 | }); 58 | }; 59 | }, 60 | 61 | afterEach() { 62 | window.navigator.requestMediaKeySystemAccess = this.origRequestMediaKeySystemAccess; 63 | this.clock.restore(); 64 | } 65 | }); 66 | 67 | QUnit.test('registers itself with video.js', function(assert) { 68 | assert.strictEqual( 69 | typeof Player.prototype.eme, 70 | 'function', 71 | 'videojs-contrib-eme plugin was registered' 72 | ); 73 | }); 74 | 75 | QUnit.test('exposes options', function(assert) { 76 | assert.notOk(this.player.eme.options, 'options is unavailable at start'); 77 | 78 | this.player.eme(); 79 | assert.deepEqual( 80 | this.player.eme.options, 81 | {}, 82 | 'options defaults to empty object once initialized' 83 | ); 84 | 85 | this.video = document.createElement('video'); 86 | this.video.setAttribute('data-setup', JSON.stringify({ 87 | plugins: { 88 | eme: { 89 | applicationId: 'application-id', 90 | publisherId: 'publisher-id' 91 | } 92 | } 93 | })); 94 | this.fixture.appendChild(this.video); 95 | this.player = videojs(this.video); 96 | 97 | assert.ok(this.player.eme.options, 'exposes options'); 98 | assert.strictEqual( 99 | this.player.eme.options.applicationId, 100 | 'application-id', 101 | 'exposes applicationId' 102 | ); 103 | assert.strictEqual( 104 | this.player.eme.options.publisherId, 105 | 'publisher-id', 106 | 'exposes publisherId' 107 | ); 108 | }); 109 | 110 | QUnit.test('exposes detectSupportedCDMs()', function(assert) { 111 | assert.notOk(this.player.eme.detectSupportedCDMs, 'detectSupportedCDMs is unavailable at start'); 112 | 113 | this.player.eme(); 114 | 115 | assert.ok(this.player.eme.detectSupportedCDMs, 'detectSupportedCDMs is available after initialization'); 116 | }); 117 | 118 | // skip test for prefix-only Safari 119 | if (!window.MediaKeys) { 120 | QUnit.test('initializeMediaKeys standard', function(assert) { 121 | assert.expect(9); 122 | const done = assert.async(); 123 | const initData = new Uint8Array([1, 2, 3]).buffer; 124 | let errors = 0; 125 | const options = { 126 | keySystems: { 127 | 'org.w3.clearkey': { 128 | pssh: initData 129 | } 130 | } 131 | }; 132 | const callback = (error) => { 133 | const sessions = this.player.eme.sessions; 134 | 135 | assert.equal(sessions.length, 1, 'created a session when keySystems in options'); 136 | assert.deepEqual(sessions[0].initData, initData, 'captured initData in the session'); 137 | assert.equal( 138 | error, 139 | 'Error: Missing url/licenseUri or getLicense in com.widevine.alpha configuration.', 140 | 'callback receives error' 141 | ); 142 | }; 143 | 144 | this.player.eme(); 145 | 146 | this.player.on('error', () => { 147 | errors++; 148 | assert.equal(errors, 1, 'error triggered only once'); 149 | assert.equal( 150 | this.player.error().message, 151 | 'Missing url/licenseUri or getLicense in com.widevine.alpha configuration.', 152 | 'error is called on player' 153 | ); 154 | this.player.error(null); 155 | }); 156 | 157 | this.player.eme.initializeMediaKeys(options, callback); 158 | // need to clear sessions to have the error trigger again 159 | this.player.eme.sessions = []; 160 | this.player.eme.initializeMediaKeys(options, callback, true); 161 | 162 | setTimeout(() => { 163 | assert.equal( 164 | this.player.error(), null, 165 | 'no error called on player with suppressError = true' 166 | ); 167 | done(); 168 | }); 169 | this.clock.tick(1); 170 | }); 171 | 172 | } 173 | 174 | QUnit.test.skip('initializeMediaKeys ms-prefix', function(assert) { 175 | assert.expect(19); 176 | const done = assert.async(); 177 | // stub setMediaKeys 178 | const setMediaKeys = this.player.tech_.el_.setMediaKeys; 179 | let throwError = true; 180 | let errors = 0; 181 | let keySession; 182 | let errorMessage; 183 | const origMediaKeys = window.MediaKeys; 184 | const origWebKitMediaKeys = window.WebKitMediaKeys; 185 | 186 | window.MediaKeys = undefined; 187 | window.WebKitMediaKeys = undefined; 188 | 189 | if (!window.MSMediaKeys) { 190 | window.MSMediaKeys = function() {}; 191 | } 192 | 193 | this.player.tech_.el_.setMediaKeys = null; 194 | if (!this.player.tech_.el_.msSetMediaKeys) { 195 | this.player.tech_.el_.msSetMediaKeys = () => { 196 | this.player.tech_.el_.msKeys = { 197 | createSession: () => { 198 | if (throwError) { 199 | throw new Error('error creating keySession'); 200 | } else { 201 | keySession = new videojs.EventTarget(); 202 | return keySession; 203 | } 204 | } 205 | }; 206 | }; 207 | } 208 | 209 | const initData = new Uint8Array([1, 2, 3]).buffer; 210 | const options = { 211 | keySystems: { 212 | 'com.microsoft.playready': { 213 | pssh: initData 214 | } 215 | } 216 | }; 217 | const callback = (error) => { 218 | const sessions = this.player.eme.sessions; 219 | 220 | assert.equal(sessions.length, 1, 'created a session when keySystems in options'); 221 | assert.deepEqual(sessions[0].initData, initData, 'captured initData in the session'); 222 | assert.notEqual(error, undefined, 'callback receives error'); 223 | 224 | }; 225 | const reset = () => { 226 | this.player.eme.sessions = []; 227 | keySession = null; 228 | }; 229 | const asyncKeySessionError = () => { 230 | if (keySession) { 231 | // we stubbed the keySession 232 | setTimeout(() => { 233 | keySession.error = {code: 1, systemCode: 2}; 234 | keySession.trigger({ 235 | target: keySession, 236 | type: 'mskeyerror' 237 | }); 238 | }); 239 | this.clock.tick(1); 240 | } 241 | }; 242 | 243 | this.player.eme(); 244 | this.player.on('error', () => { 245 | errors++; 246 | assert.equal( 247 | this.player.error().message, 248 | errorMessage, 249 | 'error is called on player' 250 | ); 251 | this.player.error(null); 252 | }); 253 | 254 | // sync error thrown by handleMsNeedKeyEvent 255 | errorMessage = 'error creating keySession'; 256 | this.player.eme.initializeMediaKeys(options, callback); 257 | reset(); 258 | this.player.eme.initializeMediaKeys(options, callback, true); 259 | reset(); 260 | // async error event on key session 261 | throwError = false; 262 | errorMessage = 'Unexpected key error from key session with code: 1 and systemCode: 2'; 263 | this.player.eme.initializeMediaKeys(options, callback); 264 | asyncKeySessionError(); 265 | reset(); 266 | this.player.eme.initializeMediaKeys(options, callback, true); 267 | asyncKeySessionError(); 268 | reset(); 269 | 270 | setTimeout(() => { 271 | // `error` will be called on the player 3 times, because a key session 272 | // error can't be suppressed on IE11 273 | assert.equal(errors, 5, 'error called on player 3 times'); 274 | assert.equal( 275 | this.player.error(), null, 276 | 'no error called on player with suppressError = true' 277 | ); 278 | window.MediaKeys = origMediaKeys; 279 | window.WebKitMediaKeys = origWebKitMediaKeys; 280 | done(); 281 | }); 282 | this.clock.tick(1); 283 | 284 | this.player.tech_.el_.msSetMediaKeys = null; 285 | this.player.tech_.el_.setMediaKeys = setMediaKeys; 286 | }); 287 | 288 | QUnit.test('tech error listener is removed on dispose', function(assert) { 289 | const done = assert.async(1); 290 | let called = 0; 291 | const origMediaKeys = window.MediaKeys; 292 | const origWebKitMediaKeys = window.WebKitMediaKeys; 293 | 294 | window.MediaKeys = undefined; 295 | window.WebKitMediaKeys = undefined; 296 | if (!window.MSMediaKeys) { 297 | window.MSMediaKeys = noop.bind(this); 298 | } 299 | 300 | this.player.error = (error) => { 301 | assert.equal(error.originalError.type, 'mskeyerror', 'is expected error type'); 302 | called++; 303 | }; 304 | 305 | this.player.eme(); 306 | 307 | this.player.ready(() => { 308 | assert.equal(called, 0, 'never called'); 309 | 310 | this.player.tech_.trigger('mskeyerror'); 311 | assert.equal(called, 1, 'called once'); 312 | 313 | this.player.dispose(); 314 | this.player.tech_.trigger('mskeyerror'); 315 | assert.equal(called, 1, 'not called after player disposal'); 316 | 317 | this.player.error = undefined; 318 | window.MediaKeys = origMediaKeys; 319 | window.WebKitMediaKeys = origWebKitMediaKeys; 320 | done(); 321 | }); 322 | 323 | this.clock.tick(1); 324 | }); 325 | 326 | QUnit.test('only registers for spec-compliant events even if legacy APIs are available', function(assert) { 327 | const done = assert.async(1); 328 | 329 | const origMediaKeys = window.MediaKeys; 330 | const origMSMediaKeys = window.MSMediaKeys; 331 | const origWebKitMediaKeys = window.WebKitMediaKeys; 332 | 333 | const events = { 334 | encrypted: 0, 335 | msneedkey: 0, 336 | webkitneedkey: 0 337 | }; 338 | 339 | this.player.tech_.el_ = { 340 | addEventListener: e => events[e]++, 341 | hasAttribute: () => false 342 | }; 343 | 344 | window.MediaKeys = noop; 345 | window.MSMediaKeys = noop; 346 | window.WebKitMediaKeys = noop; 347 | 348 | this.player.eme(); 349 | 350 | this.player.ready(() => { 351 | assert.equal(events.encrypted, 1, 'registers for encrypted events'); 352 | assert.equal(events.msneedkey, 0, "doesn't register for msneedkey events"); 353 | assert.equal(events.webkitneedkey, 0, "doesn't register for webkitneedkey events"); 354 | 355 | window.MediaKeys = origMediaKeys; 356 | window.MSMediaKeys = origMSMediaKeys; 357 | window.WebKitMediaKeys = origWebKitMediaKeys; 358 | done(); 359 | }); 360 | 361 | this.clock.tick(1); 362 | 363 | }); 364 | 365 | QUnit.module('plugin guard functions', { 366 | beforeEach() { 367 | this.fixture = document.getElementById('qunit-fixture'); 368 | this.video = document.createElement('video'); 369 | this.fixture.appendChild(this.video); 370 | this.player = videojs(this.video); 371 | this.options = { 372 | keySystems: { 373 | 'org.w3.clearkey': {url: 'some-url'} 374 | } 375 | }; 376 | 377 | this.origXhr = videojs.xhr; 378 | 379 | videojs.xhr = (params, callback) => { 380 | return callback(null, {statusCode: 200}, new Uint8Array([0, 1, 2, 3]).buffer); 381 | }; 382 | 383 | this.initData1 = new Uint8Array([1, 2, 3]).buffer; 384 | this.initData2 = new Uint8Array([4, 5, 6]).buffer; 385 | 386 | this.event1 = { 387 | // mock video target to prevent errors since it's a pain to mock out the continuation 388 | // of functionality on a successful pass through of the guards 389 | target: {}, 390 | initData: this.initData1 391 | }; 392 | this.event2 = { 393 | target: {}, 394 | initData: this.initData2 395 | }; 396 | 397 | if (!window.MSMediaKeys) { 398 | window.MSMediaKeys = noop.bind(this); 399 | } 400 | if (!window.WebKitMediaKeys) { 401 | window.WebKitMediaKeys = noop.bind(this); 402 | } 403 | 404 | this.origRequestMediaKeySystemAccess = window.navigator.requestMediaKeySystemAccess; 405 | 406 | window.navigator.requestMediaKeySystemAccess = (keySystem, options) => { 407 | return Promise.resolve({ 408 | keySystem: 'org.w3.clearkey', 409 | createMediaKeys: () => { 410 | return { 411 | createSession: () => new videojs.EventTarget() 412 | }; 413 | } 414 | }); 415 | }; 416 | }, 417 | afterEach() { 418 | window.navigator.requestMediaKeySystemAccess = this.origRequestMediaKeySystemAccess; 419 | videojs.xhr = this.origXhr; 420 | } 421 | }); 422 | 423 | QUnit.test('handleEncryptedEvent checks for required options', function(assert) { 424 | const done = assert.async(); 425 | const sessions = []; 426 | 427 | handleEncryptedEvent(this.player, this.event1, {}, sessions).then(() => { 428 | assert.equal(sessions.length, 0, 'did not create a session when no options'); 429 | done(); 430 | }); 431 | }); 432 | 433 | QUnit.test('handleEncryptedEvent checks for legacy fairplay', function(assert) { 434 | const done = assert.async(); 435 | const sessions = []; 436 | const options = { 437 | keySystems: { 438 | 'com.apple.fps.1_0': {url: 'some-url'} 439 | } 440 | }; 441 | 442 | handleEncryptedEvent(this.player, this.event1, options, sessions).then(() => { 443 | assert.equal(sessions.length, 0, 'did not create a session when no options'); 444 | done(); 445 | }); 446 | }); 447 | 448 | QUnit.test('handleEncryptedEvent checks for required init data', function(assert) { 449 | const done = assert.async(); 450 | const sessions = []; 451 | 452 | handleEncryptedEvent(this.player, { target: {}, initData: null }, this.options, sessions).then(() => { 453 | assert.equal(sessions.length, 0, 'did not create a session when no init data'); 454 | done(); 455 | }); 456 | }); 457 | 458 | QUnit.test('handleEncryptedEvent creates session', function(assert) { 459 | const done = assert.async(); 460 | const sessions = []; 461 | 462 | // testing the rejection path because this isn't a real session 463 | handleEncryptedEvent(this.player, this.event1, this.options, sessions).catch(() => { 464 | assert.equal(sessions.length, 1, 'created a session when keySystems in options'); 465 | assert.equal(sessions[0].initData, this.initData1, 'captured initData in the session'); 466 | done(); 467 | }); 468 | }); 469 | 470 | QUnit.test('handleEncryptedEvent creates new session for new init data', function(assert) { 471 | const done = assert.async(); 472 | const sessions = []; 473 | 474 | // testing the rejection path because this isn't a real session 475 | handleEncryptedEvent(this.player, this.event1, this.options, sessions).catch(() => { 476 | return handleEncryptedEvent(this.player, this.event2, this.options, sessions).catch(() => { 477 | assert.equal(sessions.length, 2, 'created a new session when new init data'); 478 | assert.equal(sessions[0].initData, this.initData1, 'retained session init data'); 479 | assert.equal(sessions[1].initData, this.initData2, 'added new session init data'); 480 | done(); 481 | }); 482 | }); 483 | }); 484 | 485 | QUnit.test('handleEncryptedEvent doesn\'t create duplicate sessions', function(assert) { 486 | const done = assert.async(); 487 | const sessions = []; 488 | 489 | // testing the rejection path because this isn't a real session 490 | handleEncryptedEvent(this.player, this.event1, this.options, sessions).catch(() => { 491 | return handleEncryptedEvent(this.player, this.event2, this.options, sessions).catch(() => { 492 | return handleEncryptedEvent(this.player, this.event2, this.options, sessions).then(() => { 493 | assert.equal(sessions.length, 2, 'no new session when same init data'); 494 | assert.equal(sessions[0].initData, this.initData1, 'retained session init data'); 495 | assert.equal(sessions[1].initData, this.initData2, 'retained session init data'); 496 | done(); 497 | }); 498 | }); 499 | }); 500 | }); 501 | 502 | QUnit.test('handleEncryptedEvent uses predefined init data', function(assert) { 503 | const done = assert.async(); 504 | const options = { 505 | keySystems: { 506 | 'org.w3.clearkey': { 507 | pssh: this.initData1 508 | } 509 | } 510 | }; 511 | const sessions = []; 512 | 513 | // testing the rejection path because this isn't a real session 514 | handleEncryptedEvent(this.player, this.event2, options, sessions).catch(() => { 515 | assert.equal(sessions.length, 1, 'created a session when keySystems in options'); 516 | assert.deepEqual(sessions[0].initData, this.initData1, 'captured initData in the session'); 517 | done(); 518 | }); 519 | }); 520 | 521 | QUnit.test('handleEncryptedEvent called explicitly on replay or seekback after `ended` if browser is Firefox ', function(assert) { 522 | const done = assert.async(); 523 | 524 | this.clock = sinon.useFakeTimers(); 525 | 526 | videojs.browser = { 527 | IS_FIREFOX: true 528 | }; 529 | this.player.eme(); 530 | 531 | this.player.trigger('ready'); 532 | this.player.trigger('play'); 533 | 534 | plug.handleEncryptedEvent = sinon.spy(); 535 | 536 | this.clock.tick(1); 537 | this.player.trigger('ended'); 538 | this.clock.tick(1); 539 | this.player.trigger('play'); 540 | assert.ok(plug.handleEncryptedEvent.calledOnce, 'HandleEncryptedEvent called if play fires after ended'); 541 | plug.handleEncryptedEvent.resetHistory(); 542 | 543 | this.player.trigger('ended'); 544 | this.player.trigger('seek'); 545 | assert.ok(plug.handleEncryptedEvent.calledOnce, 'HandleEncryptedEvent called if seek fires after ended'); 546 | plug.handleEncryptedEvent.resetHistory(); 547 | 548 | this.player.trigger('ended'); 549 | this.player.trigger('seek'); 550 | 551 | this.player.eme.sessions.push({}); 552 | 553 | this.player.trigger('play'); 554 | assert.ok(plug.handleEncryptedEvent.calledOnce, 'HandleEncryptedEvent only called once if seek and play both fire after ended'); 555 | plug.handleEncryptedEvent.resetHistory(); 556 | 557 | sinon.restore(); 558 | done(); 559 | }); 560 | 561 | QUnit.test('handleMsNeedKeyEvent uses predefined init data', function(assert) { 562 | const options = { 563 | keySystems: { 564 | 'com.microsoft.playready': { 565 | pssh: this.initData1 566 | } 567 | } 568 | }; 569 | const sessions = []; 570 | 571 | this.event2.target = { 572 | msSetMediaKeys: () => { 573 | this.event2.target.msKeys = { 574 | createSession: () => new videojs.EventTarget() 575 | }; 576 | } 577 | }; 578 | 579 | handleMsNeedKeyEvent(this.event2, options, sessions, getMockEventBus()); 580 | assert.equal(sessions.length, 1, 'created a session when keySystems in options'); 581 | assert.deepEqual(sessions[0].initData, this.initData1, 'captured initData in the session'); 582 | 583 | this.event2.target = {}; 584 | }); 585 | 586 | QUnit.test('handleMsNeedKeyEvent checks for required options', function(assert) { 587 | const event = { 588 | initData: new Uint8Array([1, 2, 3]), 589 | // mock video target to prevent errors since it's a pain to mock out the continuation 590 | // of functionality on a successful pass through of the guards 591 | target: { 592 | msSetMediaKeys() { 593 | this.msKeys = { 594 | createSession: () => new videojs.EventTarget() 595 | }; 596 | } 597 | } 598 | }; 599 | let options = {}; 600 | const sessions = []; 601 | const mockEventBus = getMockEventBus(); 602 | 603 | handleMsNeedKeyEvent(event, options, sessions, mockEventBus); 604 | assert.equal(sessions.length, 0, 'no session created when no options'); 605 | 606 | options = { keySystems: {} }; 607 | handleMsNeedKeyEvent(event, options, sessions, mockEventBus); 608 | assert.equal(sessions.length, 0, 'no session created when no PlayReady key system'); 609 | 610 | options = { keySystems: { 'com.microsoft.notplayready': true } }; 611 | handleMsNeedKeyEvent(event, options, sessions, mockEventBus); 612 | assert.equal( 613 | sessions.length, 614 | 0, 615 | 'no session created when no proper PlayReady key system' 616 | ); 617 | 618 | options = { keySystems: { 'com.microsoft.playready': true } }; 619 | handleMsNeedKeyEvent(event, options, sessions, mockEventBus); 620 | assert.equal(sessions.length, 1, 'session created'); 621 | assert.ok(sessions[0].playready, 'created a PlayReady session'); 622 | 623 | const createdSession = sessions[0]; 624 | 625 | // even when there's new init data, we should not create a new session 626 | event.initData = new Uint8Array([4, 5, 6]); 627 | 628 | handleMsNeedKeyEvent(event, options, sessions, mockEventBus); 629 | assert.equal(sessions.length, 1, 'no new session created'); 630 | assert.equal(sessions[0], createdSession, 'did not replace session'); 631 | }); 632 | 633 | QUnit.test('handleMsNeedKeyEvent checks for required init data', function(assert) { 634 | const event = { 635 | // mock video target to prevent errors since it's a pain to mock out the continuation 636 | // of functionality on a successful pass through of the guards 637 | target: {}, 638 | initData: null 639 | }; 640 | const options = { keySystems: { 'com.microsoft.playready': true } }; 641 | const sessions = []; 642 | 643 | handleMsNeedKeyEvent(event, options, sessions, getMockEventBus()); 644 | assert.equal(sessions.length, 0, 'no session created when no init data'); 645 | }); 646 | 647 | QUnit.test('handleWebKitNeedKeyEvent checks for required options', function(assert) { 648 | const event = { 649 | initData: new Uint8Array([1, 2, 3, 4]), 650 | target: {webkitSetMediaKeys: noop} 651 | }; 652 | const done = assert.async(4); 653 | let options = {}; 654 | 655 | handleWebKitNeedKeyEvent(event, options).then((val) => { 656 | assert.equal(val, undefined, 'resolves an empty promise when no options'); 657 | done(); 658 | }); 659 | 660 | options = { keySystems: {} }; 661 | handleWebKitNeedKeyEvent(event, options, {}, () => {}).then((val) => { 662 | assert.equal( 663 | val, undefined, 664 | 'resolves an empty promise when no FairPlay key system' 665 | ); 666 | done(); 667 | }); 668 | 669 | options = { keySystems: { 'com.apple.notfps.1_0': {} } }; 670 | handleWebKitNeedKeyEvent(event, options, {}, () => {}).then((val) => { 671 | assert.equal( 672 | val, undefined, 673 | 'resolves an empty promise when no proper FairPlay key system' 674 | ); 675 | done(); 676 | }); 677 | 678 | options = { keySystems: { 'com.apple.fps.1_0': {} } }; 679 | 680 | const promise = handleWebKitNeedKeyEvent(event, options, {}, () => {}); 681 | 682 | promise.catch((err) => { 683 | assert.equal( 684 | err, 'Could not create key session', 685 | 'expected error message' 686 | ); 687 | done(); 688 | }); 689 | assert.ok(promise, 'returns promise when proper FairPlay key system'); 690 | }); 691 | 692 | QUnit.test('handleWebKitNeedKeyEvent checks for required init data', function(assert) { 693 | const done = assert.async(); 694 | const event = { 695 | initData: null 696 | }; 697 | const options = { keySystems: { 'com.apple.fps.1_0': {} } }; 698 | 699 | handleWebKitNeedKeyEvent(event, options).then((val) => { 700 | assert.equal(val, undefined, 'resolves an empty promise when no init data'); 701 | done(); 702 | }); 703 | }); 704 | 705 | QUnit.module('plugin isolated functions'); 706 | 707 | QUnit.test('hasSession determines if a session exists', function(assert) { 708 | // cases in spec (where initData should always be an ArrayBuffer) 709 | const initData = new Uint8Array([1, 2, 3]).buffer; 710 | 711 | assert.notOk(hasSession([], initData), 'false when no sessions'); 712 | assert.ok( 713 | hasSession([{ initData }], initData), 714 | 'true when initData is present in a session' 715 | ); 716 | assert.ok( 717 | hasSession([ 718 | {}, 719 | { initData: new Uint8Array([1, 2, 3]).buffer } 720 | ], initData), 721 | 'true when same initData contents present in a session' 722 | ); 723 | assert.notOk( 724 | hasSession([{ initData: new Uint8Array([1, 2]).buffer }], initData), 725 | 'false when initData contents not present in a session' 726 | ); 727 | 728 | // cases outside of spec (where initData is not always an ArrayBuffer) 729 | assert.ok( 730 | hasSession([{ initData: new Uint8Array([1, 2, 3]) }], initData), 731 | 'true even if session initData is a typed array and initData is an ArrayBuffer' 732 | ); 733 | assert.ok( 734 | hasSession( 735 | [{ initData: new Uint8Array([1, 2, 3]).buffer }], 736 | new Uint8Array([1, 2, 3]) 737 | ), 738 | 'true even if session initData is an ArrayBuffer and initData is a typed array' 739 | ); 740 | assert.ok( 741 | hasSession([{ initData: new Uint8Array([1, 2, 3]) }], new Uint8Array([1, 2, 3])), 742 | 'true even if both session initData and initData are typed arrays' 743 | ); 744 | }); 745 | 746 | QUnit.test('setupSessions sets up sessions for new sources', function(assert) { 747 | // mock the player with an eme plugin object attached to it 748 | let src = 'some-src'; 749 | const player = { eme: {}, src: () => src }; 750 | 751 | setupSessions(player); 752 | 753 | assert.ok( 754 | Array.isArray(player.eme.sessions), 755 | 'creates a sessions array when none exist' 756 | ); 757 | assert.equal(player.eme.sessions.length, 0, 'sessions array is empty'); 758 | assert.equal(player.eme.activeSrc, 'some-src', 'set activeSrc property'); 759 | 760 | setupSessions(player); 761 | 762 | assert.equal(player.eme.sessions.length, 0, 'sessions array is still empty'); 763 | assert.equal(player.eme.activeSrc, 'some-src', 'activeSrc property did not change'); 764 | 765 | player.eme.sessions.push({}); 766 | src = 'other-src'; 767 | setupSessions(player); 768 | 769 | assert.equal(player.eme.sessions.length, 0, 'sessions array reset'); 770 | assert.equal(player.eme.activeSrc, 'other-src', 'activeSrc property changed'); 771 | 772 | player.eme.sessions.push({}); 773 | setupSessions(player); 774 | 775 | assert.equal(player.eme.sessions.length, 1, 'sessions array unchanged'); 776 | assert.equal(player.eme.activeSrc, 'other-src', 'activeSrc property unchanged'); 777 | }); 778 | 779 | QUnit.test('getOptions prioritizes eme options over source options', function(assert) { 780 | const player = { 781 | eme: { 782 | options: { 783 | keySystems: { 784 | keySystem1: { 785 | audioContentType: 'audio-content-type', 786 | videoContentType: 'video-content-type' 787 | }, 788 | keySystem3: { 789 | licenseUrl: 'license-url-3' 790 | } 791 | }, 792 | extraOption: 'extra-option' 793 | } 794 | }, 795 | currentSource() { 796 | return { 797 | keySystems: { 798 | keySystem1: { 799 | licenseUrl: 'license-url-1', 800 | videoContentType: 'source-video-content-type' 801 | }, 802 | keySystem2: { 803 | licenseUrl: 'license-url-2' 804 | } 805 | }, 806 | type: 'application/dash+xml' 807 | }; 808 | } 809 | }; 810 | 811 | assert.deepEqual(getOptions(player), { 812 | keySystems: { 813 | keySystem1: { 814 | audioContentType: 'audio-content-type', 815 | videoContentType: 'video-content-type', 816 | licenseUrl: 'license-url-1' 817 | }, 818 | keySystem2: { 819 | licenseUrl: 'license-url-2' 820 | }, 821 | keySystem3: { 822 | licenseUrl: 'license-url-3' 823 | } 824 | }, 825 | type: 'application/dash+xml', 826 | extraOption: 'extra-option' 827 | }, 'updates source options with eme options'); 828 | }); 829 | 830 | QUnit.test('removeSession removes sessions', function(assert) { 831 | const initData1 = new Uint8Array([1, 2, 3]); 832 | const initData2 = new Uint8Array([2, 3, 4]); 833 | const initData3 = new Uint8Array([3, 4, 5]); 834 | const sessions = [{ 835 | initData: initData1 836 | }, { 837 | initData: initData2 838 | }, { 839 | initData: initData3 840 | }]; 841 | 842 | removeSession(sessions, initData2); 843 | assert.deepEqual( 844 | sessions, 845 | [{ initData: initData1 }, { initData: initData3 }], 846 | 'removed session with initData' 847 | ); 848 | 849 | removeSession(sessions, null); 850 | assert.deepEqual( 851 | sessions, 852 | [{ initData: initData1 }, { initData: initData3 }], 853 | 'does nothing when passed null' 854 | ); 855 | 856 | removeSession(sessions, new Uint8Array([6, 7, 8])); 857 | assert.deepEqual( 858 | sessions, 859 | [{ initData: initData1 }, { initData: initData3 }], 860 | 'does nothing when passed non-matching initData' 861 | ); 862 | 863 | removeSession(sessions, new Uint8Array([1, 2, 3])); 864 | assert.deepEqual( 865 | sessions, 866 | [{ initData: initData1 }, { initData: initData3 }], 867 | 'did not remove session because initData is not the same reference' 868 | ); 869 | 870 | removeSession(sessions, initData1); 871 | assert.deepEqual( 872 | sessions, 873 | [{ initData: initData3 }], 874 | 'removed session with initData' 875 | ); 876 | removeSession(sessions, initData3); 877 | assert.deepEqual(sessions, [], 'removed session with initData'); 878 | removeSession(sessions, initData2); 879 | assert.deepEqual(sessions, [], 'does nothing when no sessions'); 880 | }); 881 | 882 | QUnit.test('emeError properly handles various parameter types', function(assert) { 883 | let errorObj; 884 | const player = { 885 | tech_: { 886 | el_: new videojs.EventTarget() 887 | }, 888 | error: (obj) => { 889 | errorObj = obj; 890 | } 891 | }; 892 | const emeError = emeErrorHandler(player); 893 | 894 | emeError(undefined); 895 | assert.equal(errorObj.message, null, 'null error message'); 896 | 897 | emeError({}); 898 | assert.equal(errorObj.message, null, 'null error message'); 899 | 900 | emeError(new Error('some error')); 901 | assert.equal(errorObj.message, 'some error', 'use error text when error'); 902 | 903 | emeError('some string'); 904 | assert.equal(errorObj.message, 'some string', 'use string when string'); 905 | 906 | emeError({type: 'mskeyerror', message: 'some event'}); 907 | assert.equal(errorObj.message, 'some event', 'use message property when object has it'); 908 | 909 | const metadata = { 910 | errorType: 'foo', 911 | keySystem: 'bar', 912 | config: { 913 | 'com.apple.fps.1_0': { 914 | certificateUri: 'foo.bar.certificate', 915 | licenseUri: 'bar.foo.license' 916 | } 917 | } 918 | }; 919 | const errorString = 'string error'; 920 | 921 | emeError(errorString, metadata); 922 | assert.equal(errorObj.message, errorString, 'error message is expected value'); 923 | assert.equal(errorObj.metadata, metadata, 'metadata object is expected value'); 924 | 925 | const mockErrorObject = { 926 | type: 'foo', 927 | message: errorString 928 | }; 929 | 930 | emeError(mockErrorObject, metadata); 931 | assert.equal(errorObj.originalError, mockErrorObject, 'originalError object is added to new errorObject'); 932 | assert.equal(errorObj.message, errorString, 'error message is expected value'); 933 | assert.equal(errorObj.metadata, metadata, 'metadata object is expected value'); 934 | }); 935 | -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | export const stringToArrayBuffer = (string) => { 2 | const buffer = new ArrayBuffer(string.length * 2); 3 | const typedArray = new Uint16Array(buffer); 4 | 5 | for (let i = 0; i < string.length; i++) { 6 | typedArray[i] = string.charCodeAt(i); 7 | } 8 | 9 | return buffer; 10 | }; 11 | 12 | export const getMockEventBus = () => { 13 | const calls = []; 14 | const mockEventBus = { 15 | calls, 16 | trigger: (event) => { 17 | calls.push(event); 18 | }, 19 | isDisposed: () => { 20 | return false; 21 | } 22 | }; 23 | 24 | return mockEventBus; 25 | }; 26 | -------------------------------------------------------------------------------- /test/utils.test.js: -------------------------------------------------------------------------------- 1 | import QUnit from 'qunit'; 2 | 3 | import { 4 | arrayBuffersEqual, 5 | arrayBufferFrom, 6 | mergeAndRemoveNull, 7 | getMediaKeySystemConfigurations 8 | } from '../src/utils'; 9 | 10 | QUnit.module('utils'); 11 | 12 | QUnit.test('arrayBuffersEqual checks if two array buffers are equal', function(assert) { 13 | assert.ok( 14 | arrayBuffersEqual(new ArrayBuffer(3), new ArrayBuffer(3)), 15 | 'same size empty array buffers are equal' 16 | ); 17 | assert.notOk( 18 | arrayBuffersEqual(new ArrayBuffer(2), new ArrayBuffer(3)), 19 | 'different size empty array buffers are not equal' 20 | ); 21 | 22 | const arrayBuffer = new ArrayBuffer(10); 23 | 24 | assert.ok(arrayBuffersEqual(arrayBuffer, arrayBuffer), 'same array buffer is equal'); 25 | 26 | assert.ok( 27 | arrayBuffersEqual(new Uint8Array([1, 2, 3]).buffer, new Uint8Array([1, 2, 3]).buffer), 28 | 'array buffers with same content are equal' 29 | ); 30 | assert.notOk( 31 | arrayBuffersEqual(new Uint8Array([1, 2, 3]).buffer, new Uint8Array([1, 2, 4]).buffer), 32 | 'array buffers with different content are not equal' 33 | ); 34 | assert.notOk( 35 | arrayBuffersEqual(new Uint8Array([1, 2, 3]).buffer, new Uint8Array([1, 2]).buffer), 36 | 'array buffers with different content lengths are not equal' 37 | ); 38 | }); 39 | 40 | QUnit.test('arrayBufferFrom returns buffer from typed arrays', function(assert) { 41 | const uint8Array = new Uint8Array([1, 2, 3]); 42 | let buffer = arrayBufferFrom(uint8Array); 43 | 44 | assert.ok(buffer instanceof ArrayBuffer, 'returned an ArrayBuffer'); 45 | assert.equal(buffer, uint8Array.buffer, 'buffer is the Uint8Array\'s buffer'); 46 | 47 | const uint16Array = new Uint16Array([4, 5, 6]); 48 | 49 | buffer = arrayBufferFrom(uint16Array); 50 | assert.ok(buffer instanceof ArrayBuffer, 'returned an ArrayBuffer'); 51 | assert.equal(buffer, uint16Array.buffer, 'buffer is the Uint16Array\'s buffer'); 52 | 53 | buffer = arrayBufferFrom(buffer); 54 | assert.ok(buffer instanceof ArrayBuffer, 'buffer is still an ArrayBuffer'); 55 | assert.equal(buffer, uint16Array.buffer, 'buffer is the same buffer'); 56 | }); 57 | 58 | QUnit.test('mergeAndRemoveNull removes property if value is null', function(assert) { 59 | const object1 = { 60 | a: 'a', 61 | b: 'b', 62 | c: 'c' 63 | }; 64 | const object2 = { 65 | a: 'A', 66 | b: null 67 | }; 68 | 69 | const resultObj = mergeAndRemoveNull(object1, object2); 70 | 71 | assert.deepEqual(resultObj, { 72 | a: 'A', 73 | c: 'c' 74 | }, 'successfully merged and removed null property'); 75 | }); 76 | 77 | QUnit.test('getMediaKeySystemConfigurations returns MediaKeySystemConfiguration array', function(assert) { 78 | const config = getMediaKeySystemConfigurations({ 79 | 'com.widevine.alpha': { 80 | audioContentType: 'audio/mp4; codecs="mp4a.40.2"', 81 | audioRobustness: 'SW_SECURE_CRYPTO', 82 | videoContentType: 'video/mp4; codecs="avc1.42E01E"', 83 | videoRobustness: 'SW_SECURE_CRYPTO' 84 | } 85 | }); 86 | 87 | const expectedConfig = [{ 88 | audioCapabilities: [ 89 | { 90 | contentType: 'audio/mp4; codecs=\"mp4a.40.2\"', 91 | robustness: 'SW_SECURE_CRYPTO' 92 | } 93 | ], 94 | videoCapabilities: [ 95 | { 96 | contentType: 'video/mp4; codecs=\"avc1.42E01E\"', 97 | robustness: 'SW_SECURE_CRYPTO' 98 | } 99 | ] 100 | }]; 101 | 102 | assert.deepEqual(config, expectedConfig, 'getMediaKeysystemConfigurations returns expected values'); 103 | }); 104 | --------------------------------------------------------------------------------