Repository: shaka-project/shaka-player (2025-07-03T10:57:42Z)

Issue:
HLS + cbcs w/ Widevine playback fails without error
**Have you read the [FAQ](https://bit.ly/ShakaFAQ) and checked for duplicate open issues?**
yep


**If the problem is related to FairPlay, have you read the tutorial?**
unrelated to FairPlay


**What version of Shaka Player are you using?**
Tested with the demo player, which was v4.15.5 at this writing.
Also tested in a couple of the "best" older version of the demo player:
* v4.14.21
* v4.13.25
* v4.12.18
* v4.11.21
* v4.10.26

This stream actually plays successfully in v4.9.36, so I didn't test any version older than that.


**Can you reproduce the issue with our latest release version?**
Yes


**Can you reproduce the issue with the latest code from `main`?**
Yes


**Are you using the demo app or your own custom app?**
Demo app


**If custom app, can you reproduce the issue using our demo app?**
n/a


**What browser and OS are you using?**
Chrome Version 138.0.7204.49 (Official Build) (x86_64)
MacOS Ventura 13.4


**For embedded devices (smart TVs, etc.), what model and firmware version are you using?**
n/a

**What are the manifest and license server URIs?**
Manifest: https://content.uplynk.com/channel/e4bd472a0f1440748fb19a143de6246e.m3u8?expand=drm_wv_l3
License Server: https://content.uplynk.com/wv


**What configuration are you using?  What is the output of `player.getNonDefaultConfiguration()`?**
This command fails when executing in the console of the demo player page. Have added no additional configuration beyond manifest URL and license server URL.


**What did you do?**
Attempt to play the stream in the demo player.


**What did you expect to happen?**
Stream plays


**What actually happened?**
1. HLS manifest is requested and returned (observable in browser dev tools network panel)
2. HLS media playlist for video variant is requested and returned
3. HLS media playlist for audio variant is requested and returned
4. Init segments and media segments are requested and returned
5. Widevine license is requested and returned
6. Player "loading" spinner disappears, but default poster image remains.
7. Media playlists for both audio and video continue to be re-requested and returned
8. Media segments continue to be requested and returned
9. Playback never begins.
10. No errors are displayed in the console.

Interestingly, if I provide a master manifest which lists exactly one video variant + the audio variant, playback succeeds. Example of such a manifest URL: https://content.uplynk.com/channel/e4bd472a0f1440748fb19a143de6246e.m3u8?expand=drm_wv_l3&rays=e


**Are you planning to send a PR to fix it?**
I'm afraid I am unsure where to start a contribution here. I would be happy to provide one given some guidance.


Discussion:

Patch:
diff --git a/lib/media/preload_manager.js b/lib/media/preload_manager.js
index 01f4f6516a..92b4582bdf 100644
--- a/lib/media/preload_manager.js
+++ b/lib/media/preload_manager.js
@@ -430,13 +430,18 @@ shaka.media.PreloadManager = class extends shaka.util.FakeEventTarget {
         await this.parseManifestInner_();
         this.throwIfDestroyed_();
 
+        await this.chooseInitialVariant_();
+        this.throwIfDestroyed_();
+
         if (!shaka.drm.DrmUtils.isMediaKeysPolyfilled('webkit')) {
           await this.initializeDrm();
           this.throwIfDestroyed_();
         }
 
-        await this.chooseInitialVariantAndPrefetchInner_();
-        this.throwIfDestroyed_();
+        if (this.allowPrefetch_) {
+          await this.prefetchInner_();
+          this.throwIfDestroyed_();
+        }
 
         // We don't need the drm keys to load completely for the initial variant
         // to be chosen, but we won't mark the load as a success until it has
@@ -552,19 +557,6 @@ shaka.media.PreloadManager = class extends shaka.util.FakeEventTarget {
     if (!this.manifest_) {
       this.manifest_ = await this.parser_.start(
           this.assetUri_, this.manifestPlayerInterface_);
-
-      if (this.manifest_.variants.length == 1) {
-        const createSegmentIndexPromises = [];
-        const variant = this.manifest_.variants[0];
-        for (const stream of [variant.video, variant.audio]) {
-          if (stream && !stream.segmentIndex) {
-            createSegmentIndexPromises.push(stream.createSegmentIndex());
-          }
-        }
-        if (createSegmentIndexPromises.length > 0) {
-          await Promise.all(createSegmentIndexPromises);
-        }
-      }
     }
 
     this.manifestPromise_.resolve();
@@ -590,6 +582,16 @@ shaka.media.PreloadManager = class extends shaka.util.FakeEventTarget {
     // audio-video.
     shaka.media.PreloadManager.filterForAVVariants_(this.manifest_);
 
+    const tracksChangedInitial = this.manifestFilterer_.applyRestrictions(
+        this.manifest_);
+    if (tracksChangedInitial) {
+      const event = this.makeEvent_(
+          shaka.util.FakeEvent.EventName.TracksChanged);
+      await Promise.resolve();
+      this.throwIfDestroyed_();
+      this.dispatchEvent(event);
+    }
+
     const now = Date.now() / 1000;
     const delta = now - startTime;
     this.stats_.setManifestTime(delta);
@@ -614,23 +616,10 @@ shaka.media.PreloadManager = class extends shaka.util.FakeEventTarget {
 
     this.drmEngine_.configure(this.config_.drm, () => this.isPreload_);
 
-    const tracksChangedInitial = this.manifestFilterer_.applyRestrictions(
-        this.manifest_);
-    if (tracksChangedInitial) {
-      const event = this.makeEvent_(
-          shaka.util.FakeEvent.EventName.TracksChanged);
-      await Promise.resolve();
-      this.throwIfDestroyed_();
-      this.dispatchEvent(event);
-    }
-
     const playableVariants = shaka.util.StreamUtils.getPlayableVariants(
         this.manifest_.variants);
     let isLive = true;
-    // In HLS we need to parse the media playlist to know if it is Live or not.
-    // So at this point we don't know yet. By default we assume it is Live.
-    if (this.manifest_ && this.manifest_.presentationTimeline &&
-        this.manifest_.type != shaka.media.ManifestParser.HLS) {
+    if (this.manifest_ && this.manifest_.presentationTimeline) {
       isLive = this.manifest_.presentationTimeline.isLive();
     }
     await this.drmEngine_.initForPlayback(
@@ -679,13 +668,13 @@ shaka.media.PreloadManager = class extends shaka.util.FakeEventTarget {
   }
 
   /**
-   * Performs a final filtering of the manifest, and chooses the initial
-   * variant. Also prefetches segments.
+   * Performs a filtering of the manifest, and chooses the initial
+   * variant.
    *
    * @return {!Promise}
    * @private
    */
-  async chooseInitialVariantAndPrefetchInner_() {
+  async chooseInitialVariant_() {
     goog.asserts.assert(
         this.manifest_, 'The manifest should already be parsed.');
 
@@ -724,40 +713,92 @@ shaka.media.PreloadManager = class extends shaka.util.FakeEventTarget {
       this.abrManager_.configure(this.config_.abr);
     }
 
-    if (this.allowPrefetch_) {
-      const isLive = this.manifest_.presentationTimeline.isLive();
-      // Prefetch segments for the predicted first variant.
-      // We start these here, but don't wait for them; it's okay to start the
-      // full load process while the segments are being prefetched.
-      const playableVariants = shaka.util.StreamUtils.getPlayableVariants(
-          this.manifest_.variants);
-      const adaptationSet = this.currentAdaptationSetCriteria_.create(
-          playableVariants);
-      // Guess what the first variant will be, based on a SimpleAbrManager.
-      this.abrManager_.configure(this.config_.abr);
-      this.abrManager_.setVariants(Array.from(adaptationSet.values()));
-      const variant = this.abrManager_.chooseVariant();
-      if (variant) {
-        const promises = [];
-        this.prefetchedVariant_ = variant;
-        if (variant.video) {
-          promises.push(this.prefetchStream_(variant.video, isLive));
-        }
-        if (variant.audio) {
-          promises.push(this.prefetchStream_(variant.audio, isLive));
-        }
-        const textStream = this.chooseTextStream_();
-        if (textStream && shaka.util.StreamUtils.shouldInitiallyShowText(
-            variant.audio, textStream, this.config_)) {
-          promises.push(this.prefetchStream_(textStream, isLive));
-          this.prefetchedTextStream_ = textStream;
+    const variant = this.configureAbrManagerAndChooseVariant_();
+    if (variant &&
+        this.shouldCreateSegmentIndexBeforeDrmEngineInitialization_()) {
+      const createSegmentIndexPromises = [];
+      for (const stream of [variant.video, variant.audio]) {
+        if (stream && !stream.segmentIndex) {
+          createSegmentIndexPromises.push(stream.createSegmentIndex());
         }
+      }
+      if (createSegmentIndexPromises.length > 0) {
+        await Promise.all(createSegmentIndexPromises);
+      }
+    }
+  }
 
-        await Promise.all(promises);
+  /**
+   * @return {boolean}
+   * @private
+   */
+  shouldCreateSegmentIndexBeforeDrmEngineInitialization_() {
+    goog.asserts.assert(
+        this.manifest_, 'The manifest should already be parsed.');
+
+    // If we only have one variant, it is useful to preload it, because it will
+    // be the only one we can use.
+    if (this.manifest_.variants.length == 1) {
+      return true;
+    }
+
+    // In HLS, DRM information is usually included in the media playlist, so we
+    // need to download the media playlist to get the real information.
+    if (this.manifest_.type == shaka.media.ManifestParser.HLS) {
+      return true;
+    }
+
+    return false;
+  }
+
+  /**
+   * Prefetches segments.
+   *
+   * @return {!Promise}
+   * @private
+   */
+  async prefetchInner_() {
+    const variant = this.configureAbrManagerAndChooseVariant_();
+    if (variant) {
+      const isLive = this.manifest_.presentationTimeline.isLive();
+      const promises = [];
+      this.prefetchedVariant_ = variant;
+      if (variant.video) {
+        promises.push(this.prefetchStream_(variant.video, isLive));
       }
+      if (variant.audio) {
+        promises.push(this.prefetchStream_(variant.audio, isLive));
+      }
+      const textStream = this.chooseTextStream_();
+      if (textStream && shaka.util.StreamUtils.shouldInitiallyShowText(
+          variant.audio, textStream, this.config_)) {
+        promises.push(this.prefetchStream_(textStream, isLive));
+        this.prefetchedTextStream_ = textStream;
+      }
+
+      await Promise.all(promises);
     }
   }
 
+  /**
+   * @return {shaka.extern.Variant}
+   * @private
+   */
+  configureAbrManagerAndChooseVariant_() {
+    goog.asserts.assert(this.currentAdaptationSetCriteria_,
+        'Must have an AdaptationSetCriteria');
+
+    const playableVariants = shaka.util.StreamUtils.getPlayableVariants(
+        this.manifest_.variants);
+    const adaptationSet = this.currentAdaptationSetCriteria_.create(
+        playableVariants);
+    // Guess what the first variant will be, based on a SimpleAbrManager.
+    this.abrManager_.configure(this.config_.abr);
+    this.abrManager_.setVariants(Array.from(adaptationSet.values()));
+
+    return this.abrManager_.chooseVariant();
+  }
+
   /**
    * @return {?shaka.extern.Stream}
    * @private


Test patch:
diff --git a/test/player_unit.js b/test/player_unit.js
index 2b6183a268..77ab7fb493 100644
--- a/test/player_unit.js
+++ b/test/player_unit.js
@@ -4157,7 +4157,8 @@ describe('Player', () => {
 
       await player.load(fakeManifestUri, 0, fakeMimeType);
       expect(abrManager.setVariants).toHaveBeenCalled();
-      const variants = abrManager.setVariants.calls.argsFor(0)[0];
+      const lastCallIndex = abrManager.setVariants.calls.count() - 1;
+      const variants = abrManager.setVariants.calls.argsFor(lastCallIndex)[0];
       // We've already chosen codecs, so only 3 tracks should remain.
       expect(variants.length).toBe(3);
       // They should be the low-bandwidth ones.