Merge pull request #246 from videojs/live-hlse-fixes
Fix discontinuities
Showing
4 changed files
with
179 additions
and
285 deletions
... | @@ -70,7 +70,7 @@ | ... | @@ -70,7 +70,7 @@ |
70 | type="application/x-mpegURL"> | 70 | type="application/x-mpegURL"> |
71 | </video> | 71 | </video> |
72 | <script> | 72 | <script> |
73 | videojs.options.flash.swf = 'node_modules/video.js/dist/video-js/video-js.swf'; | 73 | videojs.options.flash.swf = 'node_modules/videojs-swf/dist/video-js.swf'; |
74 | // initialize the player | 74 | // initialize the player |
75 | var player = videojs('video'); | 75 | var player = videojs('video'); |
76 | </script> | 76 | </script> | ... | ... |
... | @@ -221,14 +221,13 @@ videojs.Hls.prototype.src = function(src) { | ... | @@ -221,14 +221,13 @@ videojs.Hls.prototype.src = function(src) { |
221 | this.mediaIndex = videojs.Hls.translateMediaIndex(this.mediaIndex, oldMediaPlaylist, updatedPlaylist); | 221 | this.mediaIndex = videojs.Hls.translateMediaIndex(this.mediaIndex, oldMediaPlaylist, updatedPlaylist); |
222 | oldMediaPlaylist = updatedPlaylist; | 222 | oldMediaPlaylist = updatedPlaylist; |
223 | 223 | ||
224 | this.fetchKeys(updatedPlaylist, this.mediaIndex); | 224 | this.fetchKeys_(); |
225 | })); | 225 | })); |
226 | 226 | ||
227 | this.playlists.on('mediachange', videojs.bind(this, function() { | 227 | this.playlists.on('mediachange', videojs.bind(this, function() { |
228 | // abort outstanding key requests and check if new keys need to be retrieved | 228 | // abort outstanding key requests and check if new keys need to be retrieved |
229 | if (keyXhr) { | 229 | if (keyXhr) { |
230 | this.cancelKeyXhr(); | 230 | this.cancelKeyXhr(); |
231 | this.fetchKeys(this.playlists.media(), this.mediaIndex); | ||
232 | } | 231 | } |
233 | 232 | ||
234 | player.trigger('mediachange'); | 233 | player.trigger('mediachange'); |
... | @@ -330,11 +329,10 @@ videojs.Hls.prototype.setCurrentTime = function(currentTime) { | ... | @@ -330,11 +329,10 @@ videojs.Hls.prototype.setCurrentTime = function(currentTime) { |
330 | // cancel outstanding requests and buffer appends | 329 | // cancel outstanding requests and buffer appends |
331 | this.cancelSegmentXhr(); | 330 | this.cancelSegmentXhr(); |
332 | 331 | ||
333 | // fetch new encryption keys, if necessary | 332 | // abort outstanding key requests, if necessary |
334 | if (keyXhr) { | 333 | if (keyXhr) { |
335 | keyXhr.aborted = true; | 334 | keyXhr.aborted = true; |
336 | this.cancelKeyXhr(); | 335 | this.cancelKeyXhr(); |
337 | this.fetchKeys(this.playlists.media(), this.mediaIndex); | ||
338 | } | 336 | } |
339 | 337 | ||
340 | // clear out any buffered segments | 338 | // clear out any buffered segments |
... | @@ -659,6 +657,7 @@ videojs.Hls.prototype.loadSegment = function(segmentUri, offset) { | ... | @@ -659,6 +657,7 @@ videojs.Hls.prototype.loadSegment = function(segmentUri, offset) { |
659 | offset: offset, | 657 | offset: offset, |
660 | bytes: new Uint8Array(this.response) | 658 | bytes: new Uint8Array(this.response) |
661 | }); | 659 | }); |
660 | player.trigger('progress'); | ||
662 | tech.drainBuffer(); | 661 | tech.drainBuffer(); |
663 | 662 | ||
664 | tech.mediaIndex++; | 663 | tech.mediaIndex++; |
... | @@ -700,7 +699,8 @@ videojs.Hls.prototype.drainBuffer = function(event) { | ... | @@ -700,7 +699,8 @@ videojs.Hls.prototype.drainBuffer = function(event) { |
700 | if (keyFailed(segment.key)) { | 699 | if (keyFailed(segment.key)) { |
701 | return segmentBuffer.shift(); | 700 | return segmentBuffer.shift(); |
702 | } else if (!segment.key.bytes) { | 701 | } else if (!segment.key.bytes) { |
703 | return; | 702 | // trigger a key request if one is not already in-flight |
703 | return this.fetchKeys_(); | ||
704 | } else { | 704 | } else { |
705 | // if the media sequence is greater than 2^32, the IV will be incorrect | 705 | // if the media sequence is greater than 2^32, the IV will be incorrect |
706 | // assuming 10s segments, that would be about 1300 years | 706 | // assuming 10s segments, that would be about 1300 years |
... | @@ -714,23 +714,6 @@ videojs.Hls.prototype.drainBuffer = function(event) { | ... | @@ -714,23 +714,6 @@ videojs.Hls.prototype.drainBuffer = function(event) { |
714 | event = event || {}; | 714 | event = event || {}; |
715 | segmentOffset = videojs.Hls.getPlaylistDuration(playlist, 0, mediaIndex) * 1000; | 715 | segmentOffset = videojs.Hls.getPlaylistDuration(playlist, 0, mediaIndex) * 1000; |
716 | 716 | ||
717 | // abort() clears any data queued in the source buffer so wait | ||
718 | // until it empties before calling it when a discontinuity is | ||
719 | // next in the buffer | ||
720 | if (segment.discontinuity) { | ||
721 | if (event.type === 'waiting') { | ||
722 | this.sourceBuffer.abort(); | ||
723 | // tell the SWF where playback is continuing in the stitched timeline | ||
724 | this.el().vjs_setProperty('currentTime', segmentOffset * 0.001); | ||
725 | } else if (event.type === 'timeupdate') { | ||
726 | return; | ||
727 | } else if (typeof offset !== 'number') { | ||
728 | //if the discontinuity is reached under normal conditions, ie not a seek, | ||
729 | //the buffer already contains data and does not need to be refilled, | ||
730 | return; | ||
731 | } | ||
732 | } | ||
733 | |||
734 | // transmux the segment data from MP2T to FLV | 717 | // transmux the segment data from MP2T to FLV |
735 | this.segmentParser_.parseSegmentBinaryData(bytes); | 718 | this.segmentParser_.parseSegmentBinaryData(bytes); |
736 | this.segmentParser_.flushTags(); | 719 | this.segmentParser_.flushTags(); |
... | @@ -758,6 +741,12 @@ videojs.Hls.prototype.drainBuffer = function(event) { | ... | @@ -758,6 +741,12 @@ videojs.Hls.prototype.drainBuffer = function(event) { |
758 | this.lastSeekedTime_ = null; | 741 | this.lastSeekedTime_ = null; |
759 | } | 742 | } |
760 | 743 | ||
744 | // when we're crossing a discontinuity, inject metadata to indicate | ||
745 | // that the decoder should be reset appropriately | ||
746 | if (segment.discontinuity && tags.length) { | ||
747 | this.el().vjs_discontinuity(); | ||
748 | } | ||
749 | |||
761 | for (i = 0; i < tags.length; i++) { | 750 | for (i = 0; i < tags.length; i++) { |
762 | // queue up the bytes to be appended to the SourceBuffer | 751 | // queue up the bytes to be appended to the SourceBuffer |
763 | // the queue gives control back to the browser between tags | 752 | // the queue gives control back to the browser between tags |
... | @@ -776,11 +765,19 @@ videojs.Hls.prototype.drainBuffer = function(event) { | ... | @@ -776,11 +765,19 @@ videojs.Hls.prototype.drainBuffer = function(event) { |
776 | } | 765 | } |
777 | }; | 766 | }; |
778 | 767 | ||
779 | videojs.Hls.prototype.fetchKeys = function(playlist, index) { | 768 | /** |
780 | var i, key, tech, player, settings, view; | 769 | * Attempt to retrieve keys starting at a particular media |
770 | * segment. This method has no effect if segments are not yet | ||
771 | * available or a key request is already in progress. | ||
772 | * | ||
773 | * @param playlist {object} the media playlist to fetch keys for | ||
774 | * @param index {number} the media segment index to start from | ||
775 | */ | ||
776 | videojs.Hls.prototype.fetchKeys_ = function() { | ||
777 | var i, key, tech, player, settings, segment, view, receiveKey; | ||
781 | 778 | ||
782 | // if there is a pending XHR or no segments, don't do anything | 779 | // if there is a pending XHR or no segments, don't do anything |
783 | if (keyXhr || !playlist.segments) { | 780 | if (keyXhr || !this.segmentBuffer_.length) { |
784 | return; | 781 | return; |
785 | } | 782 | } |
786 | 783 | ||
... | @@ -788,22 +785,19 @@ videojs.Hls.prototype.fetchKeys = function(playlist, index) { | ... | @@ -788,22 +785,19 @@ videojs.Hls.prototype.fetchKeys = function(playlist, index) { |
788 | player = this.player(); | 785 | player = this.player(); |
789 | settings = player.options().hls || {}; | 786 | settings = player.options().hls || {}; |
790 | 787 | ||
791 | // jshint -W083 | 788 | /** |
792 | for (i = index; i < playlist.segments.length; i++) { | 789 | * Handle a key XHR response. This function needs to lookup the |
793 | key = playlist.segments[i].key; | 790 | */ |
794 | if (key && !key.bytes && !keyFailed(key)) { | 791 | receiveKey = function(key) { |
795 | keyXhr = videojs.Hls.xhr({ | 792 | return function(error) { |
796 | url: this.playlistUriToUrl(key.uri), | ||
797 | responseType: 'arraybuffer', | ||
798 | withCredentials: settings.withCredentials | ||
799 | }, function(err, url) { | ||
800 | keyXhr = null; | 793 | keyXhr = null; |
801 | 794 | ||
802 | if (err || !this.response || this.response.byteLength !== 16) { | 795 | if (error || !this.response || this.response.byteLength !== 16) { |
803 | key.retries = key.retries || 0; | 796 | key.retries = key.retries || 0; |
804 | key.retries++; | 797 | key.retries++; |
805 | if (!this.aborted) { | 798 | if (!this.aborted) { |
806 | tech.fetchKeys(playlist, i); | 799 | // try fetching again |
800 | tech.fetchKeys_(); | ||
807 | } | 801 | } |
808 | return; | 802 | return; |
809 | } | 803 | } |
... | @@ -815,12 +809,31 @@ videojs.Hls.prototype.fetchKeys = function(playlist, index) { | ... | @@ -815,12 +809,31 @@ videojs.Hls.prototype.fetchKeys = function(playlist, index) { |
815 | view.getUint32(8), | 809 | view.getUint32(8), |
816 | view.getUint32(12) | 810 | view.getUint32(12) |
817 | ]); | 811 | ]); |
818 | tech.fetchKeys(playlist, i++, url); | 812 | |
819 | }); | 813 | // check to see if this allows us to make progress buffering now |
814 | tech.checkBuffer_(); | ||
815 | }; | ||
816 | }; | ||
817 | |||
818 | for (i = 0; i < tech.segmentBuffer_.length; i++) { | ||
819 | segment = tech.segmentBuffer_[i].playlist.segments[tech.segmentBuffer_[i].mediaIndex]; | ||
820 | key = segment.key; | ||
821 | |||
822 | // continue looking if this segment is unencrypted | ||
823 | if (!key) { | ||
824 | continue; | ||
825 | } | ||
826 | |||
827 | // request the key if the retry limit hasn't been reached | ||
828 | if (!key.bytes && !keyFailed(key)) { | ||
829 | keyXhr = videojs.Hls.xhr({ | ||
830 | url: this.playlistUriToUrl(key.uri), | ||
831 | responseType: 'arraybuffer', | ||
832 | withCredentials: settings.withCredentials | ||
833 | }, receiveKey(key)); | ||
820 | break; | 834 | break; |
821 | } | 835 | } |
822 | } | 836 | } |
823 | // jshint +W083 | ||
824 | }; | 837 | }; |
825 | 838 | ||
826 | /** | 839 | /** |
... | @@ -925,9 +938,7 @@ videojs.Hls.getPlaylistTotalDuration = function(playlist) { | ... | @@ -925,9 +938,7 @@ videojs.Hls.getPlaylistTotalDuration = function(playlist) { |
925 | * playlist | 938 | * playlist |
926 | */ | 939 | */ |
927 | videojs.Hls.translateMediaIndex = function(mediaIndex, original, update) { | 940 | videojs.Hls.translateMediaIndex = function(mediaIndex, original, update) { |
928 | var i, | 941 | var translatedMediaIndex; |
929 | originalSegment, | ||
930 | translatedMediaIndex; | ||
931 | 942 | ||
932 | // no segments have been loaded from the original playlist | 943 | // no segments have been loaded from the original playlist |
933 | if (mediaIndex === 0) { | 944 | if (mediaIndex === 0) { |
... | @@ -939,15 +950,8 @@ videojs.Hls.translateMediaIndex = function(mediaIndex, original, update) { | ... | @@ -939,15 +950,8 @@ videojs.Hls.translateMediaIndex = function(mediaIndex, original, update) { |
939 | return 0; | 950 | return 0; |
940 | } | 951 | } |
941 | 952 | ||
942 | // try to sync based on URI | 953 | // translate based on media sequence numbers. syncing up across |
943 | i = update.segments.length; | 954 | // bitrate switches should be happening here. |
944 | originalSegment = original.segments[mediaIndex - 1]; | ||
945 | while (i--) { | ||
946 | if (originalSegment.uri === update.segments[i].uri) { | ||
947 | return i + 1; | ||
948 | } | ||
949 | } | ||
950 | |||
951 | translatedMediaIndex = (mediaIndex + (original.mediaSequence - update.mediaSequence)); | 955 | translatedMediaIndex = (mediaIndex + (original.mediaSequence - update.mediaSequence)); |
952 | 956 | ||
953 | if (translatedMediaIndex >= update.segments.length || translatedMediaIndex < 0) { | 957 | if (translatedMediaIndex >= update.segments.length || translatedMediaIndex < 0) { |
... | @@ -955,7 +959,6 @@ videojs.Hls.translateMediaIndex = function(mediaIndex, original, update) { | ... | @@ -955,7 +959,6 @@ videojs.Hls.translateMediaIndex = function(mediaIndex, original, update) { |
955 | return videojs.Hls.getMediaIndexForLive_(update) + 1; | 959 | return videojs.Hls.getMediaIndexForLive_(update) + 1; |
956 | } | 960 | } |
957 | 961 | ||
958 | // sync on media sequence | ||
959 | return translatedMediaIndex; | 962 | return translatedMediaIndex; |
960 | }; | 963 | }; |
961 | 964 | ... | ... |
... | @@ -54,6 +54,7 @@ var | ... | @@ -54,6 +54,7 @@ var |
54 | tech.vjs_setProperty = function() {}; | 54 | tech.vjs_setProperty = function() {}; |
55 | tech.vjs_src = function() {}; | 55 | tech.vjs_src = function() {}; |
56 | tech.vjs_play = function() {}; | 56 | tech.vjs_play = function() {}; |
57 | tech.vjs_discontinuity = function() {}; | ||
57 | videojs.Flash.onReady(tech.id); | 58 | videojs.Flash.onReady(tech.id); |
58 | 59 | ||
59 | return player; | 60 | return player; |
... | @@ -86,7 +87,7 @@ var | ... | @@ -86,7 +87,7 @@ var |
86 | contentType = 'video/MP2T'; | 87 | contentType = 'video/MP2T'; |
87 | } | 88 | } |
88 | 89 | ||
89 | request.response = new Uint8Array([1]).buffer; | 90 | request.response = new Uint8Array(16).buffer; |
90 | request.respond(200, | 91 | request.respond(200, |
91 | { 'Content-Type': contentType }, | 92 | { 'Content-Type': contentType }, |
92 | window.manifests[manifestName]); | 93 | window.manifests[manifestName]); |
... | @@ -571,6 +572,23 @@ test('calculates the bandwidth after downloading a segment', function() { | ... | @@ -571,6 +572,23 @@ test('calculates the bandwidth after downloading a segment', function() { |
571 | 'saves segment request time: ' + player.hls.segmentXhrTime + 's'); | 572 | 'saves segment request time: ' + player.hls.segmentXhrTime + 's'); |
572 | }); | 573 | }); |
573 | 574 | ||
575 | test('fires a progress event after downloading a segment', function() { | ||
576 | var progressCount = 0; | ||
577 | |||
578 | player.src({ | ||
579 | src: 'manifest/media.m3u8', | ||
580 | type: 'application/vnd.apple.mpegurl' | ||
581 | }); | ||
582 | openMediaSource(player); | ||
583 | standardXHRResponse(requests.shift()); | ||
584 | player.on('progress', function() { | ||
585 | progressCount++; | ||
586 | }); | ||
587 | standardXHRResponse(requests.shift()); | ||
588 | |||
589 | equal(progressCount, 1, 'fired a progress event'); | ||
590 | }); | ||
591 | |||
574 | test('selects a playlist after segment downloads', function() { | 592 | test('selects a playlist after segment downloads', function() { |
575 | var calls = 0; | 593 | var calls = 0; |
576 | player.src({ | 594 | player.src({ |
... | @@ -1221,6 +1239,7 @@ test('updates the media index when a playlist reloads', function() { | ... | @@ -1221,6 +1239,7 @@ test('updates the media index when a playlist reloads', function() { |
1221 | // reload the updated playlist | 1239 | // reload the updated playlist |
1222 | player.hls.playlists.media = function() { | 1240 | player.hls.playlists.media = function() { |
1223 | return { | 1241 | return { |
1242 | mediaSequence: 1, | ||
1224 | segments: [{ | 1243 | segments: [{ |
1225 | uri: '1.ts' | 1244 | uri: '1.ts' |
1226 | }, { | 1245 | }, { |
... | @@ -1348,9 +1367,10 @@ test('does not break if the playlist has no segments', function() { | ... | @@ -1348,9 +1367,10 @@ test('does not break if the playlist has no segments', function() { |
1348 | strictEqual(requests.length, 1, 'no requests for non-existent segments were queued'); | 1367 | strictEqual(requests.length, 1, 'no requests for non-existent segments were queued'); |
1349 | }); | 1368 | }); |
1350 | 1369 | ||
1351 | test('waits until the buffer is empty before appending bytes at a discontinuity', function() { | 1370 | test('calls vjs_discontinuity() before appending bytes at a discontinuity', function() { |
1352 | var aborts = 0, setTime, currentTime, bufferEnd; | 1371 | var discontinuities = 0, tags = [], currentTime, bufferEnd; |
1353 | 1372 | ||
1373 | videojs.Hls.SegmentParser = mockSegmentParser(tags); | ||
1354 | player.src({ | 1374 | player.src({ |
1355 | src: 'discontinuity.m3u8', | 1375 | src: 'discontinuity.m3u8', |
1356 | type: 'application/vnd.apple.mpegurl' | 1376 | type: 'application/vnd.apple.mpegurl' |
... | @@ -1360,13 +1380,8 @@ test('waits until the buffer is empty before appending bytes at a discontinuity' | ... | @@ -1360,13 +1380,8 @@ test('waits until the buffer is empty before appending bytes at a discontinuity' |
1360 | player.buffered = function() { | 1380 | player.buffered = function() { |
1361 | return videojs.createTimeRange(0, bufferEnd); | 1381 | return videojs.createTimeRange(0, bufferEnd); |
1362 | }; | 1382 | }; |
1363 | player.hls.sourceBuffer.abort = function() { | 1383 | player.el().querySelector('.vjs-tech').vjs_discontinuity = function() { |
1364 | aborts++; | 1384 | discontinuities++; |
1365 | }; | ||
1366 | player.hls.el().vjs_setProperty = function(name, value) { | ||
1367 | if (name === 'currentTime') { | ||
1368 | return setTime = value; | ||
1369 | } | ||
1370 | }; | 1385 | }; |
1371 | 1386 | ||
1372 | requests.pop().respond(200, null, | 1387 | requests.pop().respond(200, null, |
... | @@ -1382,15 +1397,11 @@ test('waits until the buffer is empty before appending bytes at a discontinuity' | ... | @@ -1382,15 +1397,11 @@ test('waits until the buffer is empty before appending bytes at a discontinuity' |
1382 | currentTime = 6; | 1397 | currentTime = 6; |
1383 | bufferEnd = 10; | 1398 | bufferEnd = 10; |
1384 | player.hls.checkBuffer_(); | 1399 | player.hls.checkBuffer_(); |
1385 | strictEqual(aborts, 0, 'no aborts before the buffer empties'); | 1400 | strictEqual(discontinuities, 0, 'no discontinuities before the segment is received'); |
1386 | 1401 | ||
1402 | tags.push({}); | ||
1387 | standardXHRResponse(requests.pop()); | 1403 | standardXHRResponse(requests.pop()); |
1388 | strictEqual(aborts, 0, 'no aborts before the buffer empties'); | 1404 | strictEqual(discontinuities, 1, 'signals a discontinuity'); |
1389 | |||
1390 | // pretend the buffer has emptied | ||
1391 | player.trigger('waiting'); | ||
1392 | strictEqual(aborts, 1, 'aborted before appending the new segment'); | ||
1393 | strictEqual(setTime, 10, 'updated the time after crossing the discontinuity'); | ||
1394 | }); | 1405 | }); |
1395 | 1406 | ||
1396 | test('clears the segment buffer on seek', function() { | 1407 | test('clears the segment buffer on seek', function() { |
... | @@ -1781,40 +1792,23 @@ test('drainBuffer will not proceed with empty source buffer', function() { | ... | @@ -1781,40 +1792,23 @@ test('drainBuffer will not proceed with empty source buffer', function() { |
1781 | player.hls.playlists.media = oldMedia; | 1792 | player.hls.playlists.media = oldMedia; |
1782 | }); | 1793 | }); |
1783 | 1794 | ||
1784 | test('calling fetchKeys() when a new playlist is loaded will create an XHR', function() { | 1795 | test('keys are requested when an encrypted segment is loaded', function() { |
1785 | player.src({ | 1796 | player.src({ |
1786 | src: 'https://example.com/encrypted-media.m3u8', | 1797 | src: 'https://example.com/encrypted.m3u8', |
1787 | type: 'application/vnd.apple.mpegurl' | 1798 | type: 'application/vnd.apple.mpegurl' |
1788 | }); | 1799 | }); |
1789 | openMediaSource(player); | 1800 | openMediaSource(player); |
1790 | 1801 | ||
1791 | var oldMedia = player.hls.playlists.media; | 1802 | standardXHRResponse(requests.shift()); // playlist |
1792 | player.hls.playlists.media = function() { | 1803 | standardXHRResponse(requests.shift()); // first segment |
1793 | return { | ||
1794 | segments: [{ | ||
1795 | key: { | ||
1796 | 'method': 'AES-128', | ||
1797 | 'uri': 'https://priv.example.com/key.php?r=52' | ||
1798 | }, | ||
1799 | uri: 'http://media.example.com/fileSequence52-A.ts' | ||
1800 | }, { | ||
1801 | key: { | ||
1802 | 'method': 'AES-128', | ||
1803 | 'uri': 'https://priv.example.com/key.php?r=53' | ||
1804 | }, | ||
1805 | uri: 'http://media.example.com/fileSequence53-B.ts' | ||
1806 | }] | ||
1807 | }; | ||
1808 | }; | ||
1809 | |||
1810 | player.hls.playlists.trigger('loadedplaylist'); | ||
1811 | strictEqual(requests.length, 2, 'a key XHR is created'); | ||
1812 | strictEqual(requests[1].url, player.hls.playlists.media().segments[0].key.uri, 'a key XHR is created with correct uri'); | ||
1813 | 1804 | ||
1814 | player.hls.playlists.media = oldMedia; | 1805 | strictEqual(requests.length, 1, 'a key XHR is created'); |
1806 | strictEqual(requests[0].url, | ||
1807 | player.hls.playlists.media().segments[0].key.uri, | ||
1808 | 'a key XHR is created with correct uri'); | ||
1815 | }); | 1809 | }); |
1816 | 1810 | ||
1817 | test('fetchKeys() resolves URLs relative to the master playlist', function() { | 1811 | test('keys are resolved relative to the master playlist', function() { |
1818 | player.src({ | 1812 | player.src({ |
1819 | src: 'video/master-encrypted.m3u8', | 1813 | src: 'video/master-encrypted.m3u8', |
1820 | type: 'application/vnd.apple.mpegurl' | 1814 | type: 'application/vnd.apple.mpegurl' |
... | @@ -1833,12 +1827,13 @@ test('fetchKeys() resolves URLs relative to the master playlist', function() { | ... | @@ -1833,12 +1827,13 @@ test('fetchKeys() resolves URLs relative to the master playlist', function() { |
1833 | 'http://media.example.com/fileSequence1.ts\n' + | 1827 | 'http://media.example.com/fileSequence1.ts\n' + |
1834 | '#EXT-X-ENDLIST\n'); | 1828 | '#EXT-X-ENDLIST\n'); |
1835 | 1829 | ||
1836 | equal(requests.length, 2, 'requested two URLs'); | 1830 | standardXHRResponse(requests.shift()); |
1831 | equal(requests.length, 1, 'requested the key'); | ||
1837 | ok((/video\/playlist\/keys\/key\.php$/).test(requests[0].url), | 1832 | ok((/video\/playlist\/keys\/key\.php$/).test(requests[0].url), |
1838 | 'resolves multiple relative paths'); | 1833 | 'resolves multiple relative paths'); |
1839 | }); | 1834 | }); |
1840 | 1835 | ||
1841 | test('fetchKeys() resolves URLs relative to their containing playlist', function() { | 1836 | test('keys are resolved relative to their containing playlist', function() { |
1842 | player.src({ | 1837 | player.src({ |
1843 | src: 'video/media-encrypted.m3u8', | 1838 | src: 'video/media-encrypted.m3u8', |
1844 | type: 'application/vnd.apple.mpegurl' | 1839 | type: 'application/vnd.apple.mpegurl' |
... | @@ -1851,197 +1846,100 @@ test('fetchKeys() resolves URLs relative to their containing playlist', function | ... | @@ -1851,197 +1846,100 @@ test('fetchKeys() resolves URLs relative to their containing playlist', function |
1851 | '#EXTINF:2.833,\n' + | 1846 | '#EXTINF:2.833,\n' + |
1852 | 'http://media.example.com/fileSequence1.ts\n' + | 1847 | 'http://media.example.com/fileSequence1.ts\n' + |
1853 | '#EXT-X-ENDLIST\n'); | 1848 | '#EXT-X-ENDLIST\n'); |
1854 | equal(requests.length, 2, 'requested two URLs'); | 1849 | standardXHRResponse(requests.shift()); |
1850 | equal(requests.length, 1, 'requested a key'); | ||
1855 | ok((/video\/keys\/key\.php$/).test(requests[0].url), | 1851 | ok((/video\/keys\/key\.php$/).test(requests[0].url), |
1856 | 'resolves multiple relative paths'); | 1852 | 'resolves multiple relative paths'); |
1857 | }); | 1853 | }); |
1858 | 1854 | ||
1859 | test('a new keys XHR is created when a previous key XHR finishes', function() { | 1855 | test('a new key XHR is created when a the segment is received', function() { |
1860 | player.src({ | 1856 | player.src({ |
1861 | src: 'https://example.com/encrypted-media.m3u8', | 1857 | src: 'https://example.com/encrypted-media.m3u8', |
1862 | type: 'application/vnd.apple.mpegurl' | 1858 | type: 'application/vnd.apple.mpegurl' |
1863 | }); | 1859 | }); |
1864 | openMediaSource(player); | 1860 | openMediaSource(player); |
1865 | 1861 | ||
1866 | var oldMedia = player.hls.playlists.media; | 1862 | requests.shift().respond(200, null, |
1867 | player.hls.playlists.media = function() { | 1863 | '#EXTM3U\n' + |
1868 | return { | 1864 | '#EXT-X-TARGETDURATION:15\n' + |
1869 | segments: [{ | 1865 | '#EXT-X-KEY:METHOD=AES-128,URI="keys/key.php"\n' + |
1870 | key: { | 1866 | '#EXTINF:2.833,\n' + |
1871 | 'method': 'AES-128', | 1867 | 'http://media.example.com/fileSequence1.ts\n' + |
1872 | 'uri': 'https://priv.example.com/key.php?r=52' | 1868 | '#EXT-X-KEY:METHOD=AES-128,URI="keys/key2.php"\n' + |
1873 | }, | 1869 | '#EXTINF:2.833,\n' + |
1874 | uri: 'http://media.example.com/fileSequence52-A.ts' | 1870 | 'http://media.example.com/fileSequence2.ts\n' + |
1875 | }, { | 1871 | '#EXT-X-ENDLIST\n'); |
1876 | key: { | 1872 | standardXHRResponse(requests.shift()); // segment 1 |
1877 | 'method': 'AES-128', | 1873 | standardXHRResponse(requests.shift()); // key 1 |
1878 | 'uri': 'https://priv.example.com/key.php?r=53' | 1874 | standardXHRResponse(requests.shift()); // segment 2 |
1879 | }, | ||
1880 | uri: 'http://media.example.com/fileSequence53-B.ts' | ||
1881 | }] | ||
1882 | }; | ||
1883 | }; | ||
1884 | // we're inject the media playlist, so drop the request | ||
1885 | requests.shift(); | ||
1886 | 1875 | ||
1887 | player.hls.playlists.trigger('loadedplaylist'); | ||
1888 | // key response | ||
1889 | requests[0].response = new Uint32Array([0, 0, 0, 0]).buffer; | ||
1890 | requests.shift().respond(200, null, ''); | ||
1891 | strictEqual(requests.length, 1, 'a key XHR is created'); | 1876 | strictEqual(requests.length, 1, 'a key XHR is created'); |
1892 | strictEqual(requests[0].url, player.hls.playlists.media().segments[1].key.uri, 'a key XHR is created with the correct uri'); | 1877 | strictEqual(requests[0].url, |
1893 | 1878 | 'https://example.com/' + | |
1894 | player.hls.playlists.media = oldMedia; | 1879 | player.hls.playlists.media().segments[1].key.uri, |
1880 | 'a key XHR is created with the correct uri'); | ||
1895 | }); | 1881 | }); |
1896 | 1882 | ||
1897 | test('calling fetchKeys() when a seek happens will create an XHR', function() { | 1883 | test('seeking should abort an outstanding key request and create a new one', function() { |
1898 | player.src({ | 1884 | player.src({ |
1899 | src: 'https://example.com/encrypted-media.m3u8', | 1885 | src: 'https://example.com/encrypted.m3u8', |
1900 | type: 'application/vnd.apple.mpegurl' | 1886 | type: 'application/vnd.apple.mpegurl' |
1901 | }); | 1887 | }); |
1902 | openMediaSource(player); | 1888 | openMediaSource(player); |
1903 | 1889 | ||
1904 | var oldMedia = player.hls.playlists.media; | ||
1905 | player.hls.playlists.media = function() { | ||
1906 | return { | ||
1907 | segments: [{ | ||
1908 | duration: 10, | ||
1909 | key: { | ||
1910 | 'method': 'AES-128', | ||
1911 | 'uri': 'https://priv.example.com/key.php?r=52' | ||
1912 | }, | ||
1913 | uri: 'http://media.example.com/fileSequence52-A.ts' | ||
1914 | }, { | ||
1915 | duration: 10, | ||
1916 | key: { | ||
1917 | 'method': 'AES-128', | ||
1918 | 'uri': 'https://priv.example.com/key.php?r=53' | ||
1919 | }, | ||
1920 | uri: 'http://media.example.com/fileSequence53-B.ts' | ||
1921 | }] | ||
1922 | }; | ||
1923 | }; | ||
1924 | 1890 | ||
1925 | player.hls.fetchKeys(player.hls.playlists.media(), 0); | 1891 | requests.shift().respond(200, null, |
1926 | player.currentTime(11); | 1892 | '#EXTM3U\n' + |
1927 | ok(requests[1].aborted, 'the key XHR should be aborted'); | 1893 | '#EXT-X-TARGETDURATION:15\n' + |
1928 | equal(requests.length, 3, 'we should get a new key XHR'); | 1894 | '#EXT-X-KEY:METHOD=AES-128,URI="keys/key.php"\n' + |
1929 | equal(requests[2].url, player.hls.playlists.media().segments[1].key.uri, 'urls should match'); | 1895 | '#EXTINF:9,\n' + |
1930 | 1896 | 'http://media.example.com/fileSequence1.ts\n' + | |
1931 | player.hls.playlists.media = oldMedia; | 1897 | '#EXT-X-KEY:METHOD=AES-128,URI="keys/key2.php"\n' + |
1932 | }); | 1898 | '#EXTINF:9,\n' + |
1933 | 1899 | 'http://media.example.com/fileSequence2.ts\n' + | |
1934 | test('calling fetchKeys() when a key XHR is in progress will *not* create an XHR', function() { | 1900 | '#EXT-X-ENDLIST\n'); |
1935 | player.src({ | 1901 | standardXHRResponse(requests.shift()); // segment 1 |
1936 | src: 'https://example.com/encrypted-media.m3u8', | ||
1937 | type: 'application/vnd.apple.mpegurl' | ||
1938 | }); | ||
1939 | openMediaSource(player); | ||
1940 | |||
1941 | var oldMedia = player.hls.playlists.media; | ||
1942 | player.hls.playlists.media = function() { | ||
1943 | return { | ||
1944 | segments: [{ | ||
1945 | key: { | ||
1946 | 'method': 'AES-128', | ||
1947 | 'uri': 'https://priv.example.com/key.php?r=52' | ||
1948 | }, | ||
1949 | uri: 'http://media.example.com/fileSequence52-A.ts' | ||
1950 | }, { | ||
1951 | key: { | ||
1952 | 'method': 'AES-128', | ||
1953 | 'uri': 'https://priv.example.com/key.php?r=53' | ||
1954 | }, | ||
1955 | uri: 'http://media.example.com/fileSequence53-B.ts' | ||
1956 | }] | ||
1957 | }; | ||
1958 | }; | ||
1959 | |||
1960 | strictEqual(requests.length, 1, 'no key XHR created for the player'); | ||
1961 | player.hls.playlists.trigger('loadedplaylist'); | ||
1962 | player.hls.fetchKeys(player.hls.playlists.media(), 0); | ||
1963 | strictEqual(requests.length, 2, 'only the original XHR is available'); | ||
1964 | |||
1965 | player.hls.playlists.media = oldMedia; | ||
1966 | }); | ||
1967 | |||
1968 | test('calling fetchKeys() when all keys are fetched, will *not* create an XHR', function() { | ||
1969 | player.src({ | ||
1970 | src: 'https://example.com/encrypted-media.m3u8', | ||
1971 | type: 'application/vnd.apple.mpegurl' | ||
1972 | }); | ||
1973 | openMediaSource(player); | ||
1974 | |||
1975 | var oldMedia = player.hls.playlists.media; | ||
1976 | player.hls.playlists.media = function() { | ||
1977 | return { | ||
1978 | segments: [{ | ||
1979 | key: { | ||
1980 | 'method': 'AES-128', | ||
1981 | 'uri': 'https://priv.example.com/key.php?r=52', | ||
1982 | bytes: new Uint8Array([1]) | ||
1983 | }, | ||
1984 | uri: 'http://media.example.com/fileSequence52-A.ts' | ||
1985 | }, { | ||
1986 | key: { | ||
1987 | 'method': 'AES-128', | ||
1988 | 'uri': 'https://priv.example.com/key.php?r=53', | ||
1989 | bytes: new Uint8Array([1]) | ||
1990 | }, | ||
1991 | uri: 'http://media.example.com/fileSequence53-B.ts' | ||
1992 | }] | ||
1993 | }; | ||
1994 | }; | ||
1995 | 1902 | ||
1996 | player.hls.fetchKeys(player.hls.playlists.media(), 0); | 1903 | player.currentTime(11); |
1997 | strictEqual(requests.length, 1, 'no XHR for keys created since they were all downloaded'); | 1904 | ok(requests[0].aborted, 'the key XHR should be aborted'); |
1905 | requests.shift(); // aborted key 1 | ||
1998 | 1906 | ||
1999 | player.hls.playlists.media = oldMedia; | 1907 | equal(requests.length, 1, 'requested the new segment'); |
1908 | standardXHRResponse(requests.shift()); // segment 2 | ||
1909 | equal(requests.length, 1, 'requested the new key'); | ||
1910 | equal(requests[0].url, | ||
1911 | 'https://example.com/' + | ||
1912 | player.hls.playlists.media().segments[1].key.uri, | ||
1913 | 'urls should match'); | ||
2000 | }); | 1914 | }); |
2001 | 1915 | ||
2002 | test('retries key requests once upon failure', function() { | 1916 | test('retries key requests once upon failure', function() { |
2003 | player.src({ | 1917 | player.src({ |
2004 | src: 'https://example.com/encrypted-media.m3u8', | 1918 | src: 'https://example.com/encrypted.m3u8', |
2005 | type: 'application/vnd.apple.mpegurl' | 1919 | type: 'application/vnd.apple.mpegurl' |
2006 | }); | 1920 | }); |
2007 | openMediaSource(player); | 1921 | openMediaSource(player); |
2008 | 1922 | ||
2009 | var oldMedia = player.hls.playlists.media; | 1923 | requests.shift().respond(200, null, |
2010 | player.hls.playlists.media = function() { | 1924 | '#EXTM3U\n' + |
2011 | return { | 1925 | '#EXT-X-KEY:METHOD=AES-128,URI="htts://priv.example.com/key.php?r=52"\n' + |
2012 | segments: [{ | 1926 | '#EXTINF:2.833,\n' + |
2013 | key: { | 1927 | 'http://media.example.com/fileSequence52-A.ts\n' + |
2014 | 'method': 'AES-128', | 1928 | '#EXT-X-KEY:METHOD=AES-128,URI="htts://priv.example.com/key.php?r=53"\n' + |
2015 | 'uri': 'https://priv.example.com/key.php?r=52' | 1929 | '#EXTINF:15.0,\n' + |
2016 | }, | 1930 | 'http://media.example.com/fileSequence53-A.ts\n'); |
2017 | uri: 'http://media.example.com/fileSequence52-A.ts' | 1931 | standardXHRResponse(requests.shift()); // segment |
2018 | }, { | 1932 | requests[0].respond(404); |
2019 | key: { | 1933 | equal(requests.length, 2, 'create a new XHR for the same key'); |
2020 | 'method': 'AES-128', | 1934 | equal(requests[1].url, requests[0].url, 'should be the same key'); |
2021 | 'uri': 'https://priv.example.com/key.php?r=53' | ||
2022 | }, | ||
2023 | uri: 'http://media.example.com/fileSequence53-B.ts' | ||
2024 | }] | ||
2025 | }; | ||
2026 | }; | ||
2027 | |||
2028 | player.hls.fetchKeys(player.hls.playlists.media(), 0); | ||
2029 | 1935 | ||
2030 | requests[1].respond(404); | 1936 | requests[1].respond(404); |
2031 | equal(requests.length, 3, 'create a new XHR for the same key'); | 1937 | equal(requests.length, 2, 'gives up after one retry'); |
2032 | equal(requests[2].url, requests[1].url, 'should be the same key'); | ||
2033 | |||
2034 | requests[2].respond(404); | ||
2035 | equal(requests.length, 4, 'create a new XHR for the same key'); | ||
2036 | notEqual(requests[3].url, requests[2].url, 'should be the same key'); | ||
2037 | equal(requests[3].url, player.hls.playlists.media().segments[1].key.uri); | ||
2038 | |||
2039 | player.hls.playlists.media = oldMedia; | ||
2040 | }); | 1938 | }); |
2041 | 1939 | ||
2042 | test('skip segments if key requests fail more than once', function() { | 1940 | test('skip segments if key requests fail more than once', function() { |
2043 | var bytes = [], | 1941 | var bytes = [], |
2044 | tags = [{ pats: 0, bytes: 0 }]; | 1942 | tags = [{ pts: 0, bytes: 0 }]; |
2045 | 1943 | ||
2046 | videojs.Hls.SegmentParser = mockSegmentParser(tags); | 1944 | videojs.Hls.SegmentParser = mockSegmentParser(tags); |
2047 | window.videojs.SourceBuffer = function() { | 1945 | window.videojs.SourceBuffer = function() { |
... | @@ -2057,7 +1955,7 @@ test('skip segments if key requests fail more than once', function() { | ... | @@ -2057,7 +1955,7 @@ test('skip segments if key requests fail more than once', function() { |
2057 | }); | 1955 | }); |
2058 | openMediaSource(player); | 1956 | openMediaSource(player); |
2059 | 1957 | ||
2060 | requests.pop().respond(200, null, | 1958 | requests.shift().respond(200, null, |
2061 | '#EXTM3U\n' + | 1959 | '#EXTM3U\n' + |
2062 | '#EXT-X-KEY:METHOD=AES-128,URI="htts://priv.example.com/key.php?r=52"\n' + | 1960 | '#EXT-X-KEY:METHOD=AES-128,URI="htts://priv.example.com/key.php?r=52"\n' + |
2063 | '#EXTINF:2.833,\n' + | 1961 | '#EXTINF:2.833,\n' + |
... | @@ -2065,32 +1963,19 @@ test('skip segments if key requests fail more than once', function() { | ... | @@ -2065,32 +1963,19 @@ test('skip segments if key requests fail more than once', function() { |
2065 | '#EXT-X-KEY:METHOD=AES-128,URI="htts://priv.example.com/key.php?r=53"\n' + | 1963 | '#EXT-X-KEY:METHOD=AES-128,URI="htts://priv.example.com/key.php?r=53"\n' + |
2066 | '#EXTINF:15.0,\n' + | 1964 | '#EXTINF:15.0,\n' + |
2067 | 'http://media.example.com/fileSequence53-A.ts\n'); | 1965 | 'http://media.example.com/fileSequence53-A.ts\n'); |
1966 | standardXHRResponse(requests.shift()); // segment 1 | ||
1967 | requests.shift().respond(404); // fail key | ||
1968 | requests.shift().respond(404); // fail key, again | ||
2068 | 1969 | ||
2069 | player.hls.playlists.trigger('loadedplaylist'); | 1970 | tags.length = 0; |
2070 | 1971 | tags.push({pts: 0, bytes: 1}); | |
2071 | player.hls.checkBuffer_(); | 1972 | player.hls.checkBuffer_(); |
2072 | 1973 | standardXHRResponse(requests.shift()); // segment 2 | |
2073 | // respond to ts segment | 1974 | equal(bytes.length, 1, 'bytes from the ts segments should not be added'); |
2074 | standardXHRResponse(requests.pop()); | ||
2075 | // fail key | ||
2076 | requests.pop().respond(404); | ||
2077 | // fail key, again | ||
2078 | requests.pop().respond(404); | ||
2079 | 1975 | ||
2080 | // key for second segment | 1976 | // key for second segment |
2081 | requests[0].response = new Uint32Array([0,0,0,0]).buffer; | 1977 | requests[0].response = new Uint32Array([0,0,0,0]).buffer; |
2082 | requests[0].respond(200, null, ''); | 1978 | requests.shift().respond(200, null, ''); |
2083 | requests.shift(); | ||
2084 | |||
2085 | equal(bytes.length, 1, 'bytes from the ts segments should not be added'); | ||
2086 | |||
2087 | player.hls.checkBuffer_(); | ||
2088 | |||
2089 | tags.length = 0; | ||
2090 | tags.push({pts: 0, bytes: 1}); | ||
2091 | |||
2092 | // second segment | ||
2093 | standardXHRResponse(requests.pop()); | ||
2094 | 1979 | ||
2095 | equal(bytes.length, 2, 'bytes from the second ts segment should be added'); | 1980 | equal(bytes.length, 2, 'bytes from the second ts segment should be added'); |
2096 | equal(bytes[1], 1, 'the bytes from the second segment are added and not the first'); | 1981 | equal(bytes[1], 1, 'the bytes from the second segment are added and not the first'); |
... | @@ -2120,10 +2005,10 @@ test('the key is supplied to the decrypter in the correct format', function() { | ... | @@ -2120,10 +2005,10 @@ test('the key is supplied to the decrypter in the correct format', function() { |
2120 | return new Uint8Array([0]); | 2005 | return new Uint8Array([0]); |
2121 | }; | 2006 | }; |
2122 | 2007 | ||
2008 | standardXHRResponse(requests.shift()); // segment | ||
2123 | requests[0].response = new Uint32Array([0,1,2,3]).buffer; | 2009 | requests[0].response = new Uint32Array([0,1,2,3]).buffer; |
2124 | requests[0].respond(200, null, ''); | 2010 | requests[0].respond(200, null, ''); |
2125 | requests.shift(); | 2011 | requests.shift(); // key |
2126 | standardXHRResponse(requests.pop()); | ||
2127 | 2012 | ||
2128 | equal(keys.length, 1, 'only one call to decrypt was made'); | 2013 | equal(keys.length, 1, 'only one call to decrypt was made'); |
2129 | deepEqual(keys[0], | 2014 | deepEqual(keys[0], |
... | @@ -2188,23 +2073,26 @@ test('switching playlists with an outstanding key request does not stall playbac | ... | @@ -2188,23 +2073,26 @@ test('switching playlists with an outstanding key request does not stall playbac |
2188 | player.hls.playlists.media = function() { | 2073 | player.hls.playlists.media = function() { |
2189 | return player.hls.playlists.master.playlists[0]; | 2074 | return player.hls.playlists.master.playlists[0]; |
2190 | }; | 2075 | }; |
2191 | // don't respond to the initial key request | ||
2192 | requests.shift(); | ||
2193 | // first segment of the original media playlist | 2076 | // first segment of the original media playlist |
2194 | standardXHRResponse(requests.shift()); | 2077 | standardXHRResponse(requests.shift()); |
2078 | // don't respond to the initial key request | ||
2079 | requests.shift(); | ||
2195 | 2080 | ||
2196 | // "switch" media | 2081 | // "switch" media |
2197 | player.hls.playlists.trigger('mediachange'); | 2082 | player.hls.playlists.trigger('mediachange'); |
2198 | 2083 | ||
2199 | player.trigger('timeupdate'); | 2084 | player.hls.checkBuffer_(); |
2200 | 2085 | ||
2201 | ok(requests.length, 'made a request'); | 2086 | ok(requests.length, 'made a request'); |
2202 | equal(requests[0].url, | 2087 | equal(requests[0].url, |
2088 | 'http://media.example.com/fileSequence52-B.ts', | ||
2089 | 'requested the segment'); | ||
2090 | equal(requests[1].url, | ||
2203 | 'https://priv.example.com/key.php?r=52', | 2091 | 'https://priv.example.com/key.php?r=52', |
2204 | 'requested the segment and key'); | 2092 | 'requested the key'); |
2205 | }); | 2093 | }); |
2206 | 2094 | ||
2207 | test('resovles relative key URLs against the playlist', function() { | 2095 | test('resolves relative key URLs against the playlist', function() { |
2208 | player.src({ | 2096 | player.src({ |
2209 | src: 'https://example.com/media.m3u8', | 2097 | src: 'https://example.com/media.m3u8', |
2210 | type: 'application/vnd.apple.mpegurl' | 2098 | type: 'application/vnd.apple.mpegurl' |
... | @@ -2217,6 +2105,8 @@ test('resovles relative key URLs against the playlist', function() { | ... | @@ -2217,6 +2105,8 @@ test('resovles relative key URLs against the playlist', function() { |
2217 | '#EXT-X-KEY:METHOD=AES-128,URI="key.php?r=52"\n' + | 2105 | '#EXT-X-KEY:METHOD=AES-128,URI="key.php?r=52"\n' + |
2218 | '#EXTINF:2.833,\n' + | 2106 | '#EXTINF:2.833,\n' + |
2219 | 'http://media.example.com/fileSequence52-A.ts\n'); | 2107 | 'http://media.example.com/fileSequence52-A.ts\n'); |
2108 | standardXHRResponse(requests.shift()); // segment | ||
2109 | |||
2220 | equal(requests[0].url, 'https://example.com/key.php?r=52', 'resolves the key URL'); | 2110 | equal(requests[0].url, 'https://example.com/key.php?r=52', 'resolves the key URL'); |
2221 | }); | 2111 | }); |
2222 | 2112 | ||
... | @@ -2243,11 +2133,11 @@ test('treats invalid keys as a key request failure', function() { | ... | @@ -2243,11 +2133,11 @@ test('treats invalid keys as a key request failure', function() { |
2243 | '#EXT-X-KEY:METHOD=NONE\n' + | 2133 | '#EXT-X-KEY:METHOD=NONE\n' + |
2244 | '#EXTINF:15.0,\n' + | 2134 | '#EXTINF:15.0,\n' + |
2245 | 'http://media.example.com/fileSequence52-B.ts\n'); | 2135 | 'http://media.example.com/fileSequence52-B.ts\n'); |
2136 | // segment request | ||
2137 | standardXHRResponse(requests.shift()); | ||
2246 | // keys should be 16 bytes long | 2138 | // keys should be 16 bytes long |
2247 | requests[0].response = new Uint8Array(1).buffer; | 2139 | requests[0].response = new Uint8Array(1).buffer; |
2248 | requests.shift().respond(200, null, ''); | 2140 | requests.shift().respond(200, null, ''); |
2249 | // segment request | ||
2250 | standardXHRResponse(requests.shift()); | ||
2251 | 2141 | ||
2252 | equal(requests[0].url, 'https://priv.example.com/key.php?r=52', 'retries the key'); | 2142 | equal(requests[0].url, 'https://priv.example.com/key.php?r=52', 'retries the key'); |
2253 | 2143 | ... | ... |
-
Please register or sign in to post a comment