shaka-player icon indicating copy to clipboard operation
shaka-player copied to clipboard

Offline storage silently failing on Safari

Open gi11es opened this issue 1 year ago • 0 comments

Have you read the FAQ and checked for duplicate open issues?

Yes

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

Yes

What version of Shaka Player are you using?

4.7.9

Can you reproduce the issue with our latest release version?

Yes

Can you reproduce the issue with the latest code from main?

Haven't tried, but nothing in the 7 commits since the 4.7.9 tag touches the offline code.

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

Custom app

If custom app, can you reproduce the issue using our demo app?

The demo app doesn't support custom response rewriting without modifying the code, which is necessary for the DRM provider I'm using (PallyCon).

What browser and OS are you using?

MacOS 14.2.1 Safari

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

n/a

What are the manifest and license server URIs?

See below, I'll share the entire repro case

What configuration are you using? What is the output of player.getConfiguration()?

See below, I'll share the entire repro case

What did you do?

Here's the entire code to repro this. It will attempt to put a random video from our catalogue into offline storage and play it back.

let currentResolution = '720p';
let currentContentId = 'layer';
let currentAssetid = false;
let keySystem = '';

async function checkDRMSupport(drmType) {
  try {
    if ('requestMediaKeySystemAccess' in navigator) {
      const config = [{
        initDataTypes: ['cenc'],
        audioCapabilities: [{contentType: 'audio/mp4;codecs="mp4a.40.2"'}],
        videoCapabilities: [{contentType: 'video/mp4;codecs="avc1.42E01E"'}]
      }];
      await navigator.requestMediaKeySystemAccess(drmType, config);
      console.log(drmType + ' is supported.');
      return true;
    } else {
      console.log(drmType + ' is not supported');
      return false;
    }
  } catch (error) {
    console.log(drmType + ' is not supported: ' + error.message);
    return false;
  }
}

async function preparePlayer(player) {
  const req = await fetch('https://license-global.pallycon.com/ri/fpsCert.do?siteId=TFEI');
  const cert = await req.arrayBuffer();

  player.configure({
    drm: {
      servers: {
        'com.widevine.alpha': 'https://nmfpoe4tycbo56gh6qsejeo3s40rsgml.lambda-url.us-east-2.on.aws/?drmType=Widevine',
        'com.apple.fps': 'https://nmfpoe4tycbo56gh6qsejeo3s40rsgml.lambda-url.us-east-2.on.aws/?drmType=FairPlay',
        'com.microsoft.playready': 'https://nmfpoe4tycbo56gh6qsejeo3s40rsgml.lambda-url.us-east-2.on.aws/?drmType=PlayReady'
      },
      advanced: {
        'com.apple.fps': {
          serverCertificate: new Uint8Array(cert)
        }
      },
      preferredKeySystems: [
        'com.apple.fps',
        // PlayReady must come before Widevine, for Windows, so it can leverage hardware protection
        'com.microsoft.playready.recommendation.3000',
        'com.microsoft.playready.recommendation', 
        'com.microsoft.playready', 
        'com.widevine.alpha'
      ],
      keySystemsMapping: {
        'com.microsoft.playready': 'com.microsoft.playready.recommendation.3000',
      }
    }
  });

  player.getNetworkingEngine().registerRequestFilter(async function (type, request) {
    console.log('Request filter: ' + type, request);
    console.log('Player key system: ' + keySystem);

    if (type == shaka.net.NetworkingEngine.RequestType.LICENSE) {
      request.headers['layer-auth-token'] = 'to-be-implemented-later';
      request.headers['layer-content-id'] = currentContentId;

      if (keySystem === 'com.apple.fps') {
        const originalPayload = new Uint8Array(request.body);
        const base64Payload = shaka.util.Uint8ArrayUtils.toBase64(originalPayload);
        const params = 'spc=' + encodeURIComponent(base64Payload);

        request.body = shaka.util.StringUtils.toUTF8(params);
        request.headers['Content-Type'] = 'application/x-www-form-urlencoded';
      }
    }
  });

  player.getNetworkingEngine().registerResponseFilter(function (type, response) {
    console.log('Response filter: ' + type, response);
    console.log('Player key system: ' + keySystem);

    if (type == shaka.net.NetworkingEngine.RequestType.LICENSE) {
      if (keySystem === 'com.apple.fps') {
        const responseText = shaka.util.StringUtils.fromUTF8(response.data).trim();
        response.data = shaka.util.Uint8ArrayUtils.fromBase64(responseText).buffer;
      }
    }
  });

  window.storage = new shaka.offline.Storage(player);
  window.storage.configure({
    offline: {
      usePersistentLicense: false,
      trackSelectionCallback: function(trackList) {
        console.log('trackSelectionCallback', trackList);
        return trackList;
      },
      downloadSizeCallback: function (size) {
        console.log('downloadSizeCallback', size);
        return true;
      }
    }
  });
}

async function playOffline(player, manifestURI) {
  let alreadyStored = false;

  await window.storage.list().then(function(content) {
    console.log('Stored offline content', content);
    content.forEach(function(line) {
      if (line.originalManifestUri == manifestURI) {
        alreadyStored = true;
      }
    });
  });
  
  if (!alreadyStored) {
    console.log('URI not already stored, storing now', manifestURI);
    await window.storage.store(manifestURI).promise;
    console.log('URI stored', manifestURI);
    // For some reason after offline storage the first play attempt seems to be a noop. Shaka bug perhaps?
    await playOffline(player, manifestURI);
    console.log('Pretended to play, now try again');
  }

  let offlineURIList = [];

  await window.storage.list().then(function(content) {
    console.log('Stored offline content', content);
    content.forEach(function(line) {
      // Offline storage will keep things from previous code iterations as well
      // We only want offline manifests that correspond to the current target manifest URI
      if (line.originalManifestUri == manifestURI) {
        offlineURIList.push(line.offlineUri);
      }
    });
  });

  console.log('Found offline content, play it', offlineURIList[0]);

  player.load(offlineURIList[0]);
}

async function getNewAssetId(resolution, codec) {
  const url = 'https://ozrp72erlzy6hdxdhgrmg7nrfq0vjjys.lambda-url.us-east-2.on.aws/';
  let request = '?resolution=' + resolution + '&codec=' + codec;
  if (currentAssetid) {
    request += '&assetid=' + currentAssetid;
  }

  const response = await fetch(url + request);
  currentAssetid = await response.text();
}

async function playDASHVP9(player) {
  await getNewAssetId(currentResolution, 'vp9');

  try {
    await playOffline(player, 'https://layer-aws-encrypted.b-cdn.net/' + currentAssetid + '/' + currentResolution +  '/vp9-q4.mp4/cmaf/stream.mpd');
  } catch (error) {
    console.error('Error code', error.code, 'object', error);
  }
}

async function playDASHHEVC(player) {
  await getNewAssetId(currentResolution, 'hevc');

  try {
    await playOffline(player, 'https://layer-aws-encrypted.b-cdn.net/' + currentAssetid + '/' + currentResolution +  '/hevc-q4.mp4/cmaf/stream.mpd');
  } catch (error) {
    console.error('Error code', error.code, 'object', error);
  }
}

async function playHLSHEVC(player) {
  await getNewAssetId(currentResolution, 'hevc');

  try {
    await playOffline(player, 'https://layer-aws-encrypted.b-cdn.net/' + currentAssetid + '/' + currentResolution +  '/hevc-q4.mp4/cmaf/master.m3u8');
  } catch (error) {
    console.error('Error code', error.code, 'object', error);
  }
}

async function play(player) {
  if (await checkDRMSupport('com.microsoft.playready')) {
    keySystem = 'com.microsoft.playready';
    return playDASHHEVC(player);
  } else if (await checkDRMSupport('com.apple.fps')) {
    keySystem = 'com.apple.fps';
    return playHLSHEVC(player);
  } else if (await checkDRMSupport('com.widevine.alpha')) {
    keySystem = 'com.widevine.alpha';
    return playDASHVP9(player);
  }
}

function onErrorEvent(event) {
  console.error('Error code', event.detail.code, 'object', event.detail);
}

async function init(domElement) {
  window.player = new shaka.Player();
  await window.player.attach(domElement);
  window.player.addEventListener('error', onErrorEvent);
  await preparePlayer(window.player);

  await play(window.player);
}

async function fetchAndGo() {
  const videoElement = document.querySelectorAll('.videoPlayer')[0];
  shaka.log.setLevel(shaka.log.Level.DEBUG);
  shaka.polyfill.installAll();

  if (shaka.Player.isBrowserSupported()) {
    await init(videoElement);
  } else {
    console.error('Browser not supported!');
  }
}

document.addEventListener('DOMContentLoaded', fetchAndGo);

What did you expect to happen?

The request manifest and streams are stored offline

What actually happened?

Execution gets stuck on:

await window.storage.store(manifestURI).promise;

By tracing execution with breakpoints, I was able to track it down to the following async call seemingly never completing:

https://github.com/shaka-project/shaka-player/blob/36b7367ebd3efafd6e46d8ef74758cd834aff224/lib/offline/storage.js#L389

It seems to be the first attempt to store something in IndexedDB.

There are no errors, execution just stays stuck there.

gi11es avatar Feb 07 '24 09:02 gi11es