Fix discontinuities
Never match media indices on playlist reload based on segment URI because some streams may re-use the same segment in multiple positions. Change fetchKeys() so that it operates on buffered segments instead of directly modifying a version of the playlist. Before that change, live playlists with low segment durations could stall because the key would be applied to the previous version of the live playlist and segments would get blocked up forever in the queue waiting for their key to arrive. Use a much less destructive mechanism for playing across discontinuities. vjs_discontinuity() on the SWF allows us to signal a timestamp discontinuity without flushing the playback buffer. That means we don't have to wait until the buffer is empty when a discontinuity is encountered and feeding data to the SWF doesn't have to block either. Update tests to reflect new key-segment request ordering.
Showing
2 changed files
with
176 additions
and
283 deletions
... | @@ -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