cover_image

使用 AVPlayer 播放 FairPlay DRM 视频的最佳实践

孙文博 搜狐技术产品
2025年04月09日 23:30

01

DRM 介绍

DRM,即数字版权管理(Digital Rights Management),是指使用加密技术保护视频内容、通过专业技术安全地存储和传输密钥(加密密钥和解密密钥)、并允许内容生产商设置商业规则,限制内容观看者的一种系统。

1.1 DRM 工作流程

  1. DRM使用对称加密算法(Symmetric-key algorithms)对视频内容进行加密,对称加密算法使用同一把密钥加密和解密;
  2. 首先,通过密钥(通常为AES-128)将内容加密,然后传输给客户端。这把密钥由专用服务器提供,安全可靠;
  3. 当客户端想要播放加密视频,就要向DRM服务器发送请求获取解密密钥;
  4. 服务器会对客户端进行鉴权,如果客户端通过鉴权,服务器就会将解密密钥和许可规则发送给它;
  5. 在收到解密密钥后,客户端使用被称为CDM(Content Decryption Module,内容解密模块)的安全软件解密,并解码视频,然后将其安全地发送给屏幕。

1.2 DRM 的几种方案

常见的 DRM 方案有下面几种,其中在 Apple 平台上,使用 FairPlay 方案:

图片

FairPlay 支持的协议

图片

我们采用的是 HLS + fmp4 的方案。

FairPlay 支持的平台和系统要求

图片

FairPlay 播放 DRM 视频的流程

  1. 用户点击播放按钮后,传递一个 .m3u8 播放地址给到 AVPlayer;
  2. 播放器下载解析 m3u8 清单文件,发现 #EXT-X-KEY,表明这是一个被加密的视频;
  3. 向系统请求 SPC 信息;
  4. 向后台请求 CKC 信息。秘钥服务器会使用收到的 SPC 中的相应信息查找内容秘钥,将其放入 CKC 返回给客户端;
  5. AVFoundation 收到 CKC 信息后,使用其中的密钥解密、解码视频,继续完成后续播放流程。

名词解释

  • SPC (Secure Playback Context),译为服务器播放上下文。里面存放的是加密后的密钥请求信息(encrypted key request);
  • CKC (Content Key Context),译为内容密钥上下文。里面存放的是加密后的密钥响应信息(encrypted key response),包含用于解密的密钥,以及该密钥的有效期;
  • KSM (Key Security Module),译为密钥安全模块,属于后端的模块;
  • CDM (Content Decryptio Module) 译为内容解密模块,属于客户端负责解密视频的模块,使用 AVPlayer 播放视频并正确提供给系统 CKC 信息后,由 AVFoundation 内部自动完成。

.m3u8 清单文件中的 EXT-X-KEY 标签示例

#EXTM3U
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-KEY:METHOD=SAMPLE-AES,URI="skd://12341234123412341234123412341234?iv=12341234123412341234123412341234"
#EXTINF:10.0,
seg-1.m4s
...
#EXTINF:2.0,
seg-35.m4s
#EXT-X-ENDLIST

下面是一张从 FPS 官方文档中裁出来的一张时序图

图片

1.3 Tip1:Apple 平台上 HLS 的 fmp4 分片的 TAG 应为 hvc1

hev1hvc1 是两种 codec tag,表示 mp4 容器中 hevc 流的不同打包方式。Quicktime Player 和 iOS 不支持 hev1 tag 的 mp4(见 https://developer.apple.com/av-foundation/HEVC-Video-with-Alpha-Interoperability-Profile.pdf page 3 最后一句话:The codec type shall be ‘hvc1’.)。

如果使用 AVPlayer 播放 tag 是 hev1 的 MP4 视频,表现会是有声音无画面。

02

管理密钥的两种方式

上面一节说过,播放 FairPlay 视频需要把正确的解密密钥拿到,才能播放 FairPlay 视频,否则会出现播放失败或者播放绿屏等异常情况。

图片

Apple 提供了两种方式来管理 FairPlay 的密钥。

  1. 使用 AVAssetResourceLoader
  2. 使用 AVContentKeySession

2.1 方式一:使用 AVAssetResourceLoader 管理秘钥

这种方式播放视频,只能在用户点击播放后,播放流程过程中去请求密钥。

图片

具体的使用方式如下:

  1. 通过 [self.urlAsset resourceLoader] 获取 AVAssetResourceLoader 对象,并设置代理 [[self.urlAsset resourceLoader] setDelegate:loaderDelegate queue:globalNotificationQueue()];
  2. 创建一个实现 AVAssetResourceLoaderDelegate 的类,实现其中的 resourceLoader: shouldWaitForRenewalOfRequestedResource: 方法;
    1. 向 iOS 系统请求 SPC 信息
    2. 向服务端请求 CKC 信息
  3. 开始播放流程 [player replaceCurrentItemWithPlayerItem:newItem]
SofaAssetLoaderDelegate *loaderDelegate = [[SofaAssetLoaderDelegate alloc] init];
loaderDelegate.fpCerData = [self fpCerData];
loaderDelegate.fpRedemptionUrl = fpRedemption;
loaderDelegate.asset = self.urlAsset;
[[self.urlAsset resourceLoader] setDelegate:loaderDelegate queue:globalNotificationQueue()];
    
[self.urlAsset loadValuesAsynchronouslyForKeys:requestedKeys completionHandler:
 ^{
    dispatch_async( dispatch_get_main_queue(), ^{
        AVPlayerItem *newItem = [AVPlayerItem playerItemWithAsset:weakSelf.urlAsset automaticallyLoadedAssetKeys:keys];
        [weakSelf.player replaceCurrentItemWithPlayerItem:newItem];
    });
}]; 


@interface SofaAssetLoaderDelegate()<AVAssetResourceLoaderDelegate>
@end
@implementation SofaAssetLoaderDelegate

- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForRenewalOfRequestedResource:(AVAssetResourceRenewalRequest *)renewalRequest
{
    return [self resourceLoader:resourceLoader shouldWaitForLoadingOfRequestedResource:renewalRequest];
}
@end


- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest
{
    AVAssetResourceLoadingDataRequest *dataRequest = loadingRequest.dataRequest;
    NSURL *url = loadingRequest.request.URL;
    NSError *error = nil;
    BOOL handled = NO;
    
    if (![[url scheme] isEqual:URL_SCHEME_NAME]) {
        return NO;
    }
    
    NSLog@"shouldWaitForLoadingOfURLRequest got %@", loadingRequest);
    
    NSString *assetStr;
    NSData *assetId;
    NSData *requestBytes;
    
    assetStr = [url host];
    assetId = [NSData dataWithBytes: [assetStr cStringUsingEncoding:NSUTF8StringEncoding] length:[assetStr lengthOfBytesUsingEncoding:NSUTF8StringEncoding]];
    
    NSLog@"contentId: %@", assetStr);
    
    NSData *certificate = self.fpCerData;
    
    // 向 iOS 系统请求获取 SPC 信息
    requestBytes = [loadingRequest streamingContentKeyRequestDataForApp:certificate
                                                      contentIdentifier:assetId
                                                                options:nil
                                                                  error:&error];
    
    
    NSData *responseData = nil;    
    // 将获取到的 SPC 发送给服务器,请求 CKC 信息
    responseData = [SofaAVContentKeyManager getlicenseWithSpcData:requestBytes
                                                contentIdentifierHost:assetStr
                                                  leaseExpiryDuration:&expiryDuration
                                                      fpRedemptionUrl:self.fpRedemptionUrl
                                                                error:&error];
    
    //Content Key Context (CKC) message from key server to application
    if (responseData != nil) {
        // Provide the CKC message (containing the CK) to the loading request.
        [dataRequest respondWithData:responseData];
        [loadingRequest finishLoading];
    } else {
        [loadingRequest finishLoadingWithError:error];
    }
    handled = YES;
    return handled;
}


// 向 KSM 后台请求密钥
+ (NSData *)getlicenseWithSpcData:(NSData *)requestBytes contentIdentifierHost:(NSString *)assetStr leaseExpiryDuration:(NSTimeInterval *)expiryDuration fpRedemptionUrl:(NSString *)fpRedemptionUrl error:(NSError **)errorOut
{
    int64_t req_start_tick = SOFA_CURRENT_TIMESTAMP_MS;
    LOGI(TAG, "going to send payload to URL %s, timestamp: %lld", [fpRedemptionUrl cStringUsingEncoding:NSUTF8StringEncoding], req_start_tick);
    LOGI(TAG, "payload length = %lu", (unsigned long)requestBytes.length);
    NSLog@"payload : %@", requestBytes);
    
    NSRange range = {15220};
    NSData* cert_hash = [requestBytes subdataWithRange:range];
    NSLog@"cert hash : %@", cert_hash);
    
    NSURL *url = [NSURL URLWithString:fpRedemptionUrl];
    NSMutableURLRequest *postRequest = [NSMutableURLRequest requestWithURL:url];
    [postRequest setHTTPMethod:@"POST"];
    [postRequest setValue:@"application/octet-stream" forHTTPHeaderField:@"Content-Type"];
    
    NSString *contentLength = [NSString stringWithFormat:@"%lu", (unsigned long)[requestBytes length]];
    [postRequest setValue:contentLength forHTTPHeaderField:@"Content-Length"];
    [postRequest setHTTPBody:requestBytes];
    
    // Send the HTTP POST request
    NSURLResponse* response = nil;
    NSData* data = [NSURLConnection sendSynchronousRequest:postRequest returningResponse:&response error:errorOut];
    int64_t req_end_tick = SOFA_CURRENT_TIMESTAMP_MS;
    LOGI(TAG, "request ckc elapsed %lld, error %d", req_end_tick - req_start_tick, (*errorOut)?1:0);
    return data;
}

上述代码,请求 SPC 信息时候,入参 certificate 就是在苹果开发者后台下载下来的证书文件:

图片

2.2 方式二:使用 AVContentKeySession

苹果还有第二种管理密钥的方式 AVContentKeySession,这个 API 是于 2017 年首次公布的,相比 AVAssetResourceLoader,它可以更好的管理秘钥,并且和视频播放过程进行解耦。

开发者可以根据用户的行为,提前下载请求即将要播放的视频的密钥信息,以加快视频的起播速度(苹果官方称之为 prewraming)。

AVContentKeySession 还支持播放离线下来的 FairPlay 视频(这个我们后面的内容中会提到)。

图片

AVContentKeySession 的使用方法简单介绍

  1. 创建 Session 并设置代理:
// 创建 session
// 用户 AVContentKeySessionDelegate 代理方法回调的线程
_keyQueue = dispatch_queue_create("com.sohuvideo.contentkeyqueue", DISPATCH_QUEUE_SERIAL); 
self.keySession = [AVContentKeySession contentKeySessionWithKeySystem:AVContentKeySystemFairPlayStreaming];
[self.keySession setDelegate:self queue:_keyQueue];
  1. 实现代理方法:
#pragma mark AVContentKeySessionDelegate
// 两种情况下会被调用:
// 1. 开发者调用了函数 -processContentKeyRequestWithIdentifier:initializationData:options: 会触发此回调。 这种情况出现在 prewarming 视频播放或下载 FairPlay 视频请求 Persistable ContentKey 的时候
// 2. 已经调用 [self.keySession addContentKeyRecipient:urlSession] ,然后正常播放 urlSession 的时候,会自动触发此回调。
- (void)contentKeySession:(nonnull AVContentKeySession *)session didProvideContentKeyRequest:(nonnull AVContentKeyRequest *)keyRequest {
    // 调用 [keyRequest makeStreamingContentKeyRequestDataForApp:contentIdentifier:options:completionHandler] 获取 spc
    // 请求后台,获取 CKC
    // 调用 [keyRequest processContentKeyResponseError:error] OR [keyRequest processContentKeyResponse:keyResponse] 结束密钥管理流程
}

// 调用 -renewExpiringResponseDataForContentKeyRequest 会触发此回调
- (void)contentKeySession:(AVContentKeySession *)session didProvideRenewingContentKeyRequest:(AVContentKeyRequest *)keyRequest {
}

// 请求 persistable content key 时候,开发者调用 respondByRequestingPersistableContentKeyRequest 函数,会触发此回调
- (void)contentKeySession:(AVContentKeySession *)session didProvidePersistableContentKeyRequest:(AVPersistableContentKeyRequest *)keyRequest {
}

// 存放在本地的 persistable content key 被使用后,这个方法可能会自动触发,这时候需更新存放在本地的 content key 数据。 (存放期 content key 更新为播放期 content key)
- (void)contentKeySession:(AVContentKeySession *)session didUpdatePersistableContentKey:(NSData *)persistableContentKey forContentKeyIdentifier:(id)keyIdentifier {
}

// 请求 content key 失败回调
- (void)contentKeySession:(AVContentKeySession *)session contentKeyRequest:(AVContentKeyRequest *)keyRequest didFailWithError:(NSError *)err {

  1. 添加 URLAsset 到 Session:
[self.keySession addContentKeyRecipient:recipient];

三个使用场景

下面分三个场景来具体介绍 AVContentKeySession 的使用

场景一:无需 prewarming,用户点击播放按钮后,使用 AVContentKeySession 管理 key request

这个场景,类似使用 AVAssetResourceLoader,都是在用户点击按钮后才去请求 FairPlay 视频的解密秘钥,视频的首帧指标会比较大。

// 添加 urlAsset 到 session
[self.keySession addContentKeyRecipient:recipient];

// 使用 AVPlayer 播放 asset
NSURL *assetUrl = [NSURL URLWithString:dataSource.path];
self.urlAsset = (AVURLAsset *)[AVAsset assetWithURL:assetUrl];;

NSArray *requestedKeys = @[@"playable"];
[self.urlAsset loadValuesAsynchronouslyForKeys:requestedKeys completionHandler: ^{
    dispatch_async( dispatch_get_main_queue(), ^{
        AVPlayerItem *newItem = [AVPlayerItem playerItemWithAsset:weakSelf.urlAsset automaticallyLoadedAssetKeys:keys];
        [weakSelf.player replaceCurrentItemWithPlayerItem:newItem];
    });
}];

// 调用 replaceCurrentItemWithPlayerItem: 开始播放后,session delegate 的回调方法 contentKeySession:didProvideContentKeyRequest: 会自动触发
- (void)contentKeySession:(nonnull AVContentKeySession *)session didProvideContentKeyRequest:(nonnull AVContentKeyRequest *)keyRequest {
    [self handleStreamingContentKeyRequest:keyRequest];
}

- (void)handleStreamingContentKeyRequest:(AVContentKeyRequest *)keyRequest {
    NSString *contentKeyIdentifierString = (NSString *)keyRequest.identifier;
    NSURL * contentKeyIdentifierURL = [NSURL URLWithString:contentKeyIdentifierString];
    NSString *assetIDString = contentKeyIdentifierURL.host;
    if  (!assetIDString || assetIDString.length == 0) {
        LOGE(TAG, "[func:%s] Failed to retrieve the assetID from the keyRequest!", __func__);
        return;
    }
    [self _handleContentKeyRequest:keyRequest];
}

// 请求 SPC 信息
- (void)_handleContentKeyRequest:(AVContentKeyRequest *)keyRequest {
    if (!self.applicationCertificate) {
        LOGE(TAG, "[func:_handleContentKeyRequest] no fairplay certificate");
        return;
    }
    
    NSString *contentKeyIdentifierString = (NSString *)keyRequest.identifier;
    NSURL * contentKeyIdentifierURL = [NSURL URLWithString:contentKeyIdentifierString];
    NSString *assetIDString = contentKeyIdentifierURL.host;
    NSData * assetIDData = [assetIDString dataUsingEncoding:NSUTF8StringEncoding];
    if  (!assetIDString || assetIDString.length == 0) {
        LOGE(TAG, "[func:_handleContentKeyRequest] Failed to retrieve the assetID from the keyRequest!");
        return;
    }
    
    __weak typeof(self) weakSelf = self;
    void (^requestSPCCallback)(NSData * _Nullable data,  NSError * _Nullable error )= ^void(NSData * _Nullable contentKeyRequestData, NSError * _Nullable error) {
        if (error) {
            LOGE(TAG, "request spc Error: %s", [error.debugDescription cStringUsingEncoding:NSUTF8StringEncoding]);
            [weakSelf _processContentKeyResponseWithRequest:keyRequest ForAssetIDString:assetIDString WithResponse:NULL error:error];
        } else {
            [weakSelf _getLicenceseWithSpcData:contentKeyRequestData contentId:assetIDString keyRequest:keyRequest];
        }
    };
    
    [keyRequest makeStreamingContentKeyRequestDataForApp:self.applicationCertificate contentIdentifier:assetIDData options:@{
        AVContentKeyRequestProtocolVersionsKey : @[@1]
    } completionHandler:requestSPCCallback];
}

// 请求 CKC 信息
- (void)_getLicenceseWithSpcData:(NSData *)spcData contentId:(NSString *)assetIDString keyRequest:(AVContentKeyRequest *)keyRequest{
    NSTimeInterval expiryDuration = 0.0;
    NSError *error;
    
    SofaContentAsset *assetContent = [self.contentKeyToStreamNameMap objectForKey:assetIDString];
    if (!assetContent) {
        LOGE(TAG, "[func:_getLicenceseWithSpcData] assetContent nul");
        return;
    }
    // http 请求:spc->ckc
    NSData *ckcData = [SofaAVContentKeyManager getlicenseWithSpcData:spcData contentIdentifierHost:assetIDString leaseExpiryDuration:&expiryDuration fpRedemptionUrl:assetContent.redemptionUrl error:&error];
    if (error) {
        LOGE(TAG, "[func:%s] CKC response Error: %s",__func__, [error.debugDescription cStringUsingEncoding:NSUTF8StringEncoding]);
        [self _processContentKeyResponseWithRequest:keyRequest ForAssetIDString:assetIDString WithResponse:NULL error:error];
    } else {
        AVContentKeyResponse *keyResponse = [AVContentKeyResponse contentKeyResponseWithFairPlayStreamingKeyResponseData:ckcData];
        [self _processContentKeyResponseWithRequest:keyRequest ForAssetIDString:assetIDString WithResponse:keyResponse error:NULL];
    }
}

- (void)_processContentKeyResponseWithRequest:(AVContentKeyRequest *)keyReq ForAssetIDString:(NSString *)assetIdString WithResponse:(AVContentKeyResponse *)keyResponse error:(NSError *)error {
    if (error) {
        [keyReq processContentKeyResponseError:error]; // 如果请求 spc 或者 ckc 某个步骤出错,需要调用 processContentKeyResponseError:error
    } else {
        [keyReq processContentKeyResponse:keyResponse]; // 通知系统请求秘钥信息成功,可以继续后续播放流程
    }
}
场景二:使用 prewarming,减少首帧时间,提升用户体验

这种情况是开发者可以根据用户行为,来预测即将播放的视频(例如预测用户会继续播放下一剧集),提前将该视频的解密秘钥获取下来,以便后续播放。

// 在合适时机,主动调用 processContentKeyRequestWithIdentifier:initializationData:options 来触发 session delegate 的回调方法 contentKeySession:didProvideContentKeyRequest:
// asset.contentId 是一个字符串,标识该加密的视频资源。 需要通过接口提前获取到。 示例: `sdk://1341234123412341234123412434`
[self.keySession processContentKeyRequestWithIdentifier:asset.contentId initializationData:NULL options:NULL];

// 后面的流程就和场景一一样了,在 session 回调方法里请求 spc,请求 ckc,告知系统秘钥请求完成或失败 [keyRequest processContentKeyResponse:keyResponse]
场景三:离线下载 FairPlay 视频,用户可以在无网情况下播放

这种情况也需要开发者在下载任务开始之前,主动调用 processContentKeyRequestWithIdentifier:initializationData:options,不同点在于需要在 session delegate 回调方法里请求 persistable key,并将其存储下来。

  1. 请求 presistable key。respondByRequestingPersistableContentKeyRequestAndReturnError:
  2. 存储解密密钥信息 persistable key。[contentKey writeToURL:fileUrl options:NSDataWritingAtomic error:&err]
  3. 使用本地的 persistable key 播放 FairPlay 视频,触发回调更新 persitable key contentKeySession: didUpdatePersistableContentKey: forContentKeyIdentifier:
// 下载任务开始之前,调用 processContentKeyRequestWithIdentifier
- (void)requestPersistableContentKeysForAsset:(SofaContentAsset *)asset {
    NSString *contentId = [asset.contentId componentsSeparatedByString:@"//"].lastObject;
    // pendingPersistableContentKeyIdentifiers 数组保存待处理的 persistable key 请求标识
    [self.pendingPersistableContentKeyIdentifiers addObject:contentId];
  
    LOGI(TAG, "[func:requestPersistableContentKeysForAsset] Requesting persistable key for assetID `\(%s)`", [contentId cStringUsingEncoding:NSUTF8StringEncoding]);    
    [self.keySession processContentKeyRequestWithIdentifier:asset.contentId initializationData:NULL options:NULL];
}

- (void)contentKeySession:(nonnull AVContentKeySession *)session didProvideContentKeyRequest:(nonnull AVContentKeyRequest *)keyRequest {
    [self handleStreamingContentKeyRequest:keyRequest];
}

- (void)handleStreamingContentKeyRequest:(AVContentKeyRequest *)keyRequest {
    NSString *contentKeyIdentifierString = (NSString *)keyRequest.identifier;
    NSURL * contentKeyIdentifierURL = [NSURL URLWithString:contentKeyIdentifierString];
    NSString *assetIDString = contentKeyIdentifierURL.host;
    
    // 如果存在待处理的 persistable key 请求或者这个视频的 persistable key 已经存放在本地了
    // 则调用 respondByRequestingPersistableContentKeyRequestAndReturnError 去请求
    // 会触发 session delegate 回调 contentKeySession:didProvidePersistableContentKeyRequest
    if([self.pendingPersistableContentKeyIdentifiers containsObject:assetIDString] ||
       [self persistableContentKeyExistsOnDiskWithContentKeyIdentifier:assetIDString]) {
        NSError *err;
        if (@available(iOS 11.2, *)) {
            // Informs the receiver to process a persistable content key request.
            [keyRequest respondByRequestingPersistableContentKeyRequestAndReturnError:&err];
            if (err) {
                [self _handleContentKeyRequest:keyRequest];
            }
        }
        return;
    }
    [self _handleContentKeyRequest:keyRequest];
}

- (void)contentKeySession:(AVContentKeySession *)session didProvidePersistableContentKeyRequest:(AVPersistableContentKeyRequest *)keyRequest {
    [self handlePersistableContentKeyRequest:keyRequest];
}

- (void)handlePersistableContentKeyRequest:(AVPersistableContentKeyRequest *)keyRequest {
    NSString *contentKeyIdentifierString = (NSString *)keyRequest.identifier;
    NSURL * contentKeyIdentifierURL = [NSURL URLWithString:contentKeyIdentifierString];
    NSString *assetIDString = contentKeyIdentifierURL.host;
    
    NSData *data = [[NSFileManager defaultManager] contentsAtPath:[self urlForPersistableContentKeyWithContentKeyIdentifier:assetIDString].path];
    if (data) {
        // 播放离线视频时,本地存在秘钥信息,直接使用
        AVContentKeyResponse *response = [AVContentKeyResponse contentKeyResponseWithFairPlayStreamingKeyResponseData:data];
        [self _processContentKeyResponseWithRequest:keyRequest ForAssetIDString:assetIDString WithResponse:response error:NULL];
    } else {
        // 开启离线下载任务时,本地还不存在秘钥信息
        // 立马启动请求秘钥流程,同在线播放。 注意此时的 keyRequest 是 AVPersistableContentKeyRequest
        [self.pendingPersistableContentKeyIdentifiers removeObject:assetIDString];
        [self _handleContentKeyRequest:keyRequest];
        return;
    }
}

// ... 中间流程的函数调用参考场景一

// 请求 ckc
- (void)_getLicenceseWithSpcData:(NSData *)spcData contentId:(NSString *)assetIDString keyRequest:(AVContentKeyRequest *)keyRequest{
    NSData *ckcData = [SofaAVContentKeyManager getlicenseWithSpcData:spcData contentIdentifierHost:assetIDString leaseExpiryDuration:&expiryDuration fpRedemptionUrl:assetContent.redemptionUrl error:&error];
    
    // 在请求下来 CKC 信息后,判断  keyRequest 是 AVPersistableContentKeyRequest,则把 CKC 存放到本地 
    if ([keyRequest isKindOfClass:[AVPersistableContentKeyRequest class]]) {
        AVPersistableContentKeyRequest *keyRequestCopy = (AVPersistableContentKeyRequest *)keyRequest;
        NSError *error2;
        NSData *persistableKeyData = [keyRequestCopy persistableContentKeyFromKeyVendorResponse:ckcData options:NULL error:&error2];
        if (error2) {
            LOGE(TAG, "[func:%s] get persistable key error: %s",__func__, [error2.debugDescription cStringUsingEncoding:NSUTF8StringEncoding]);
            [self _processContentKeyResponseWithRequest:keyRequest ForAssetIDString:assetIDString WithResponse:NULL error:error2];
            return;
        } else {
            // valid until end of storage duration. eg 30 days. 
            // when use this key to playback, MIGHT receive callback 
            // `contentKeySession: didUpdatePersistableContentKey: forContentKeyIdentifier:`
            ckcData = persistableKeyData; 
            // 写数据到本地
            [self writePersistableContentKey:ckcData withContentKeyIdentifier:assetIDString];
        }
    }

    AVContentKeyResponse *keyResponse = [AVContentKeyResponse contentKeyResponseWithFairPlayStreamingKeyResponseData:ckcData];
    [self _processContentKeyResponseWithRequest:keyRequest ForAssetIDString:assetIDString WithResponse:keyResponse error:NULL];
}

- (void)writePersistableContentKey:(NSData *)contentKey withContentKeyIdentifier:(NSString *)contentKeyIdentifier {
    NSURL *fileUrl = [self urlForPersistableContentKeyWithContentKeyIdentifier:contentKeyIdentifier];
    NSError *err;
    [contentKey writeToURL:fileUrl options:NSDataWritingAtomic error:&err];
    if (!err) {
        LOGI(TAG, "Stored the persisted content key: `\(%s)`", [fileUrl.path cStringUsingEncoding:NSUTF8StringEncoding]);
    }
}

persistent key 写入本地成功后,就可以开始使用 AVAssetDownloadTask 下载视频了,见后面小节。

播放离线视频具体流程,和场景一类似。不同点在于,在 session delegate 方法 contentKeySession:didProvideContentKeyRequest: 会判断本地存放有该视频的 persistable key 就会直接使用本地存放的 persistable key:

 NSData *data = [[NSFileManager defaultManager] contentsAtPath:[self urlForPersistableContentKeyWithContentKeyIdentifier:assetIDString].path];
if (data) {
    // 播放离线视频时,本地存在秘钥信息,直接使用
    AVContentKeyResponse *response = [AVContentKeyResponse contentKeyResponseWithFairPlayStreamingKeyResponseData:data];
    [self _processContentKeyResponseWithRequest:keyRequest ForAssetIDString:assetIDString WithResponse:response error:NULL];
}

同时在本地 persistable key 用于播放后,系统会回调 contentKeySession: didUpdatePersistableContentKey: forContentKeyIdentifier: 来更新 persistale key 中的过期时间为播放期过期时间:

- (void)contentKeySession:(AVContentKeySession *)session didUpdatePersistableContentKey:(NSData *)persistableContentKey forContentKeyIdentifier:(id)keyIdentifier {

    NSString *contentKeyIdentifierString = (NSString *) keyIdentifier;
    NSURL *contentKeyIdentifierURL = [NSURL URLWithString:contentKeyIdentifierString];
    NSString *assetIDString = contentKeyIdentifierURL.host;
    if (!contentKeyIdentifierString || !contentKeyIdentifierURL || !assetIDString) {
        LOGE(TAG, "Failed to retrieve the assetID from the keyRequest!");
        return;
    }
    LOGI(TAG, "Trying to update persistable key for asset: \(%s)", [assetIDString cStringUsingEncoding:NSUTF8StringEncoding]);    
    [self deletePeristableContentKeyWithContentKeyIdentifier:assetIDString]; // delete the old persistable key
    [self writePersistableContentKey:persistableContentKey withContentKeyIdentifier:assetIDString];// save new key, playback duration,eg:24H
}

关于 Persistable Key 过期时间

上面有提过存储期和播放期两个概念的过期时间,具体如下:

存储期 Storage Duration,是说秘钥存储到本地,在没有观看之前,称之为存储期。可以给这个存储期秘钥设置一个比较长的有效期,例如 30 天。在有效期内用户随时可以开启播放,有效期过了秘钥就自动失效。

我们在下载视频之前,请求并存储下来的 persistable key,就是存储期的秘钥。

播放期 Playback Duration,是指一旦用户开始播放视频,就到了播放期。这时候通过 contentKeySession:didUpdatePersistableContentKey:forContentKeyIdentifier 获取的秘钥就是播放期的秘钥,我们要把这个新获取的 key 替换掉之前本地存储下来的 persistable key。可以给这个播放期秘钥设置一个比较短的有效期,例如 48 小时。

假设用户在下载 FairPlay 视频后,从来没有观看过。在这种情况下,第一个密钥成为系统上的唯一密钥,超过有效期后它会自动失效。

如果使用一个失效的 key 来播放 FairPlay 视频,playerItem 会报错:

- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context {
    if (object == self.playerItem) {
        if ([keyPath isEqualToString:@"status"]) {
            if (self.playerItem.status == AVPlayerItemStatusFailed) {
                NSError *itemError =  self.player.currentItem.error;
                if ([itemError.debugDescription containsString:@"-42800"]) {
                   // Persistent Key 已过期,需重新请求
                }
            }
        }
    }
}

2.3 Tip1: 使用 fileURLWithPath 创建存放在本地路径下的媒体 URL

播放本地路径下的视频,创建 NSURL 时候需要使用 fileURLWithPath

// 不用 NSURL.init(string: <#T##String#>)
let fileUrl = NSURL.fileURL(withPath: "/Library/Caches/aHR0cDovLzEwLjI==_E0363AAE664D0C7E.movpkg")
let urlAsset = AVAsset.init(url: fileUrl)

2.4 Tip2: 使用单例管理 AVContentKeySession

关于是否使用单例来管理 AVContentKeySession 的讨论,详细可以见论坛这里https://forums.developer.apple.com/forums/thread/108708):

@interface SofaAVContentKeyManager ()<AVContentKeySessionDelegate>
@property (nonatomicstrongreadwriteAVContentKeySession *keySession;
@end

+ (instancetype)sharedInstance {
    static dispatch_once_t onceToken;
    static SofaAVContentKeyManager *instance;
    dispatch_once(&onceToken, ^{
        instance = [[self alloc] init];
    });
    return instance;
}

- (instancetype)init {
    self = [super init];
    if (self) {
        [self createKeySession];
    }
    return self;
}

- (void)createKeySession {
    _keyQueue = dispatch_queue_create("com.sohuvideo.contentkeyqueue", DISPATCH_QUEUE_SERIAL);
    self.keySession = [AVContentKeySession contentKeySessionWithKeySystem:AVContentKeySystemFairPlayStreaming];
    [self.keySession setDelegate:self queue:_keyQueue];
}

03

视频下载 AVAssetDownloadTask

3.1 使用 AVAssetDownloadTask 可以下载 HLS 视频,步骤如下:

  1. 创建 AVAssetDownloadURLSession 实例:
let hlsAsset = AVURLAsset(url: assetURL)

let backgroundConfiguration = URLSessionConfiguration.background(
    withIdentifier: "assetDownloadConfigurationIdentifier")
// AVAssetDownloadURLSession 继承自 `NSURLSession`,支持创建 `AVAssetDownloadTask`    
let assetDownloadURLSession = AVAssetDownloadURLSession(configuration: backgroundConfiguration,
    assetDownloadDelegate: self, delegateQueue: OperationQueue.main())
  1. 创建下载任务并启动:
guard let downloadTask = assetDownloadURLSession.makeAssetDownloadTask(asset: asset.urlAsset, assetTitle: asset.stream.name, assetArtworkData: nil, options: nilelse {
        return
    }
downloadTask.taskDescription = asset.stream.name
downloadTask.resume()
  1. 实现协议 AVAssetDownloadDelegate 中的下载回调方法:
// 下载任务确定好下载路径的回调
func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, willDownloadTo location: URL) {
    print("下载即将开始,路径: ", location.path)
}
    
// 下载进度更新的回调
func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didLoad timeRange: CMTimeRange, totalTimeRangesLoaded loadedTimeRanges: [NSValue], timeRangeExpectedToLoad: CMTimeRange) {
    var percentComplete = 0.0
    for value in loadedTimeRanges {
        let loadedTimeRange: CMTimeRange = value.timeRangeValue
        percentComplete +=
        CMTimeGetSeconds(loadedTimeRange.duration) / CMTimeGetSeconds(timeRangeExpectedToLoad.duration)
    }
    print("下载进度: ", percentComplete)
}
    
// 下载任务完成下载的回调
func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) {
    print("文件下载完成,存放路径: ", location.path)
}
    
// 任务结束的回调
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?) {
    guard let task = task asAVAssetDownloadTask else { return }
    if let error = error as NSError? {
        switch (error.domain, error.code) {
            case (NSURLErrorDomainNSURLErrorCancelled):
                print("用户取消")
            case (NSURLErrorDomainNSURLErrorUnknown):
                fatalError("不支持模拟器下载 HLS streams.")
            default:
                fatalError("错误发生 \(error.domain)")
        }
    } else {
        //  会在 urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) 之后调用
        print("task complete")
    }

3.2 Tip1:  下载的路径不可以自己设置,需要在下载完成后移动到想要存放的目录下

// 下载任务完成回调
func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) {
    let cachePath = NSURL.fileURL(withPath: NSSearchPathForDirectoriesInDomains(FileManager.SearchPathDirectory.cachesDirectory, FileManager.SearchPathDomainMask.userDomainMask, true).first!.appending("xxx.movpkg")) // 替换成你自己设置的缓存路径
    move(file: location, to: cachePath)
}
    
func move(file: URL, to destinationPath: URL) {
    guard FileManager.default.fileExists(atPath: file.path) else {
        print("Source file does not exist.")
        return
    }
    
    do {
        if FileManager.default.fileExists(atPath: destinationPath.path) {
            try FileManager.default.removeItem(at: destinationPath) //  如果目标路径已经有了同名文件,需要先删除,否则会 move 失败
        }
        try FileManager.default.moveItem(at: file, to: destinationPath)
    } catch {
        print("Error moving file: \(error)")
    }

3.3 Tip2: 下载后的文件是个 movpkg

mp4 和 hls 视频下载完成后的文件都是以为 movpkg 结尾的,但是使用 AVPlayer 无法播放 mp4.movpkg。只有 HLS 视频下载后的文件 hls.movpkg 是一个 bundle,可以使用AVPlayer/AVPlayerViewController 播放。

如果下载的是一个 MP4 视频,可以在下载结束调用 move(file: URL, to destinationPath: URL) 时候,把 destinationPath 设置为一个 xxx.mp4 结尾的路径,这样后续可以正常播放这个 xxx.mp4

图片
图片

3.4 Tip3: 使用 AVAggregateAssetDownloadTask

如果你的 HLS 流中包含多个不同的码率、音轨、字幕等,可以使用 AVAggregateAssetDownloadTask 来下载指定的媒体流。

使用 func aggregateAssetDownloadTask(with URLAsset: AVURLAsset, mediaSelections: [AVMediaSelection], assetTitle title: String, assetArtworkData artworkData: Data?, options: [String : Any]? = nil) -> AVAggregateAssetDownloadTask? 来创建下载任务,代码如下:

// Get the default media selections for the asset's media selection groups.
let preferredMediaSelection = asset.urlAsset.preferredMediaSelection

guard let task =
    assetDownloadURLSession.aggregateAssetDownloadTask(with: asset.urlAsset,
                                                       mediaSelections: [preferredMediaSelection], // 指定希望下载的媒体版本(例如不同的清晰度或语言轨道)
                                                       assetTitle: asset.stream.name,
                                                       assetArtworkData: nil,
                                                       options:
        [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey265_000]) // 下载时要求的最低媒体比特率为 265 kbps。这可以帮助控制下载的质量
else { return }
    
task.taskDescription = asset.stream.name
task.resume()

相应的 AVAssetDownloadDelegate 协议的回调方法也变成了下面几个:

func urlSession(_ session: URLSession, aggregateAssetDownloadTask: AVAggregateAssetDownloadTask,
                willDownloadTo location: URL)
 {
}
    
func urlSession(_ session: URLSession, aggregateAssetDownloadTask: AVAggregateAssetDownloadTask,
                didLoad timeRange: CMTimeRange, totalTimeRangesLoaded loadedTimeRanges: [NSValue],
                timeRangeExpectedToLoad: CMTimeRange, for mediaSelection: AVMediaSelection)
 {
}
    
func urlSession(_ session: URLSession, aggregateAssetDownloadTask: AVAggregateAssetDownloadTask,
                didCompleteFor mediaSelection: AVMediaSelection)
 { 
}

04

参考链接

1.Apple Developer, FairPlay Streaming(https://developer.apple.com/streaming/fps/);
2.WWDC2018, AVContentKeySession Best Practices

(https://devstreaming-cdn.apple.com/videos/wwdc/2018/507axjplrd0yjzixfz/507/507_hd_avcontentkeysession_best_practices.mp4?dl=1);

3.WWDC2020, Discover how to download and play HLS offline(https://developer.apple.com/videos/play/wwdc2020/10655/)。



继续滑动看下一个
搜狐技术产品
向上滑动看下一个