├── .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 | [](https://travis-ci.org/videojs/videojs-contrib-eme)
5 | [](https://greenkeeper.io/)
6 | [](http://slack.videojs.com)
7 |
8 | [](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 |
10 |
11 |
12 |
16 |
17 |
18 |
67 |
68 |
69 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | videojs-contrib-eme Demo
6 |
7 |
8 |
9 |
10 |
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 | 1 16 AESCTR U24lieXb6USvujjSyhfRdg== TKWDEqady2g= https://foo.bar.license 4.2.0.5545 ioydTlK2p0WXkWklprR5Hw== 13 Ef/RUojT3U6Ct2jqTCChbA== 68 U9WysleTindM/gVQyExDdw== 1706149441 WMRMServer barfoobarfoo foocipherbarcipher
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 |
--------------------------------------------------------------------------------