What causes AVAssetCache to report not playable offline for a fully downloaded asset?

2.2k views Asked by At

I'm working on an iOS app that plays FairPlay-encrypted audio via HLS, and supports both downloading and streaming. And I'm unable to play downloaded content when in airplane mode. If I create an AVURLAsset from the local URL when the download completes, asset.assetCache.isPlayableOffline returns NO, and sure enough when I try to play in airplane mode it still tries to request one of the .m3u8 playlist files.

My master playlist looks like this:

#EXTM3U
# Created with Bento4 mp4-hls.py version 1.1.0r623

#EXT-X-VERSION:5
#EXT-X-SESSION-KEY:METHOD=SAMPLE-AES,URI="skd://url/to/key?KID=foobar",KEYFORMAT="com.apple.streamingkeydelivery",KEYFORMATVERSIONS="1"


# Media Playlists
#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=133781,BANDWIDTH=134685,CODECS="mp4a.40.2" media-1/stream.m3u8
#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=67526,BANDWIDTH=67854,CODECS="mp4a.40.2" media-2/stream.m3u8

The stream playlists look like this:

#EXTM3U
#EXT-X-VERSION:5
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-TARGETDURATION:30
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-KEY:METHOD=SAMPLE-AES,URI="skd://url/to/key?KID=foobar",KEYFORMAT="com.apple.streamingkeydelivery",KEYFORMATVERSIONS="1"
#EXTINF:30.000181,
#EXT-X-BYTERANGE:470290@0
media.aac
# more segments...
#EXT-X-ENDLIST

Downloading an asset:

AVURLAsset *asset = [AVURLAsset assetWithURL:myM3u8Url];
[asset.resourceLoader setDelegate:[FairPlayKeyManager instance] queue:[FairPlayKeyManager queue]];
asset.resourceLoader.preloadsEligibleContentKeys = YES;
AVAssetDownloadTask *task = [self.session assetDownloadTaskWithURLAsset:asset assetTitle:@"Track" assetArtworkData:imgData options:nil];
[task resume];

In the delegate's URLSession:assetDownloadTask:didFinishDownloadingToURL::

self.downloadedPath = location.relativePath;

In the delegate's URLSession:task:didCompleteWithError::

if (!error)
{
  NSString *strUrl = [NSHomeDirectory() stringByAppendingPathComponent:self.downloadedPath];
  NSURL *url = [NSURL fileURLWithPath:strUrl];
  AVURLAsset *localAsset = [AVURLAsset assetWithURL:url];
  if (!localAsset.assetCache.playableOffline)
    NSLog(@"Oh no!"); //not playable offline
}

The download doesn't give an error besides the asset cache reporting not playable offline. But if you switch to airplane mode and try to play the downloaded asset, it'll properly ask the resource loader delegate for a key (and I'm using persistent keys, so that works fine offline), then try to make a request for media-1/stream.m3u8.

Are there any gotchas that I'm not handling here? Should the playlist file be different in some way? Is there some property on the task or asset that I'm missing?

2

There are 2 answers

1
Tom Hamming On BEST ANSWER

As it turned out, this was because the URL I was downloading the audio from (ex. https://mywebsite.com/path/to/master.m3u8 was redirecting to a CDN url (https://my.cdn/other/path/to/master.m3u8). Something was going wrong in the AVAssetDownloadTask bookkeeping such that when I tried to play the resulting downloaded files offline, it thought it needed more files from the network. I've filed this as radar 43285278. I solved this by manually doing a HEAD request to the same URL, then giving AVAssetDownloadTask the resulting redirect URL.

2
Slowpoke On

I think you have few things to check before checking asset.assetCache.isPlayableOffline.

  1. Does your KSM configured to support fairplay offline play?
    • visit Apple's FairPlay Streaming Website
    • download fairplay sample SDK (FairPlay Streaming Server SDK (4.2.0))
    • open HLSCatalogWithFPS - AVAssetResourceLoader or HLSCatalogWithFPS - AVContentKeySession
    • adapt your KSM to sample project to check if FPS offline play works well
  2. Check your key requesting process
    • since you didn't provide any of code related to key requesting process, I have no idea if you properly requested & received ckc data
    • completing download does not mean you have acquired ckc nor persistable key. Debug to check if you're getting right ckc data from KSM. (You will probably get error when requesting ckc with persistent key option if your KSM didn't configure the content as offline playable)
func handlePersistableContentKeyRequest(keyRequest: AVPersistableContentKeyRequest) {

        /*
         The key ID is the URI from the EXT-X-KEY tag in the playlist (e.g. "skd://key65") and the
         asset ID in this case is "key65".
         */
        guard let contentKeyIdentifierString = keyRequest.identifier as? String,
            let contentKeyIdentifierURL = URL(string: contentKeyIdentifierString),
            let assetIDString = contentKeyIdentifierURL.host,
            let assetIDData = assetIDString.data(using: .utf8)
            else {
                print("Failed to retrieve the assetID from the keyRequest!")
                return
        }

        do {

            let completionHandler = { [weak self] (spcData: Data?, error: Error?) in
                guard let strongSelf = self else { return }
                if let error = error {
                    keyRequest.processContentKeyResponseError(error)

                    strongSelf.pendingPersistableContentKeyIdentifiers.remove(assetIDString)
                    return
                }

                guard let spcData = spcData else { return }

                do {
                    // Send SPC to Key Server and obtain CKC
                    let ckcData = try strongSelf.requestContentKeyFromKeySecurityModule(spcData: spcData, assetID: assetIDString)

                    let persistentKey = try keyRequest.persistableContentKey(fromKeyVendorResponse: ckcData, options: nil)

                    try strongSelf.writePersistableContentKey(contentKey: persistentKey, withContentKeyIdentifier: assetIDString)

                    /*
                     AVContentKeyResponse is used to represent the data returned from the key server when requesting a key for
                     decrypting content.
                     */
                    let keyResponse = AVContentKeyResponse(fairPlayStreamingKeyResponseData: persistentKey)

                    /*
                     Provide the content key response to make protected content available for processing.
                     */
                    keyRequest.processContentKeyResponse(keyResponse)

                    let assetName = strongSelf.contentKeyToStreamNameMap.removeValue(forKey: assetIDString)!

                    if !strongSelf.contentKeyToStreamNameMap.values.contains(assetName) {
                        NotificationCenter.default.post(name: .DidSaveAllPersistableContentKey,
                                                        object: nil,
                                                        userInfo: ["name": assetName])
                    }

                    strongSelf.pendingPersistableContentKeyIdentifiers.remove(assetIDString)
                } catch {
                    keyRequest.processContentKeyResponseError(error)

                    strongSelf.pendingPersistableContentKeyIdentifiers.remove(assetIDString)
                }
            }

            // Check to see if we can satisfy this key request using a saved persistent key file.
            if persistableContentKeyExistsOnDisk(withContentKeyIdentifier: assetIDString) {

                let urlToPersistableKey = urlForPersistableContentKey(withContentKeyIdentifier: assetIDString)

                guard let contentKey = FileManager.default.contents(atPath: urlToPersistableKey.path) else {
                    // Error Handling.

                    pendingPersistableContentKeyIdentifiers.remove(assetIDString)

                    /*
                     Key requests should never be left dangling.
                     Attempt to create a new persistable key.
                     */
                    let applicationCertificate = try requestApplicationCertificate()
                    keyRequest.makeStreamingContentKeyRequestData(forApp: applicationCertificate,
                                                                  contentIdentifier: assetIDData,
                                                                  options: [AVContentKeyRequestProtocolVersionsKey: [1]],
                                                                  completionHandler: completionHandler)

                    return
                }

                /*
                 Create an AVContentKeyResponse from the persistent key data to use for requesting a key for
                 decrypting content.
                 */
                let keyResponse = AVContentKeyResponse(fairPlayStreamingKeyResponseData: contentKey)

                // Provide the content key response to make protected content available for processing.
                keyRequest.processContentKeyResponse(keyResponse)

                return
            }

            let applicationCertificate = try requestApplicationCertificate()

            keyRequest.makeStreamingContentKeyRequestData(forApp: applicationCertificate,
                                                          contentIdentifier: assetIDData,
                                                          options: [AVContentKeyRequestProtocolVersionsKey: [1]],
                                                          completionHandler: completionHandler)
        } catch {
            print("Failure responding to an AVPersistableContentKeyRequest when attemping to determine if key is already available for use on disk.")
        }
    }