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

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

hev1 和 hvc1 是两种 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]) {returnNO;}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:certificatecontentIdentifier:assetIdoptions:nilerror:&error];NSData *responseData = nil;    // 将获取到的 SPC 发送给服务器,请求 CKC 信息responseData = [SofaAVContentKeyManager getlicenseWithSpcData:requestBytescontentIdentifierHost:assetStrleaseExpiryDuration:&expiryDurationfpRedemptionUrl:self.fpRedemptionUrlerror:&error];//Content Key Context (CKC) message from key server to applicationif (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", (unsignedlong)requestBytes.length);NSLog( @"payload : %@", requestBytes);NSRange range = {152, 20};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", (unsignedlong)[requestBytes length]];[postRequest setValue:contentLength forHTTPHeaderField:@"Content-Length"];[postRequest setHTTPBody:requestBytes];// Send the HTTP POST requestNSURLResponse* 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];

2. 现代理方法:

#pragma mark AVContentKeySessionDelegate
// 两种情况下会被调用:
// 1. 开发者调用了函数 -processContentKeyRequestWithIdentifier:initializationData:options: 会触发此回调。 这种情况出现在 prewarming 视频播放或下载 FairPlay 视频请求 Persistable ContentKey 的时候
// 2. 已经调用 [self.keySession addContentKeyRecipient:urlSession] ,然后正常播放 urlSession 的时候,会自动触发此回调。
- (void)contentKeySession:(nonnullAVContentKeySession *)session didProvideContentKeyRequest:(nonnullAVContentKeyRequest *)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 {
}

3. 添加 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:(nonnullAVContentKeySession *)session didProvideContentKeyRequest:(nonnullAVContentKeyRequest *)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;}__weaktypeof(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->ckcNSData *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:(nonnullAVContentKeySession *)session didProvideContentKeyRequest:(nonnullAVContentKeyRequest *)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:didProvidePersistableContentKeyRequestif([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:[AVPersistableContentKeyRequestclass]]) {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 *)keyPathofObject:(id)objectchange:(NSDictionary *)changecontext:(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 (nonatomic, strong, readwrite) AVContentKeySession *keySession;
@end+ (instancetype)sharedInstance {staticdispatch_once_t onceToken;static SofaAVContentKeyManager *instance;dispatch_once(&onceToken, ^{instance = [[self alloc] init];});return instance;
}- (instancetype)init {self = [super init];if (self) {[self createKeySession];}returnself;
}- (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())

2. 创建下载任务并启动:

guard let downloadTask = assetDownloadURLSession.makeAssetDownloadTask(asset: asset.urlAsset, assetTitle: asset.stream.name, assetArtworkData: nil, options: nil) else {return}
downloadTask.taskDescription = asset.stream.name
downloadTask.resume()

3. 实现协议 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.0for value in loadedTimeRanges {let loadedTimeRange: CMTimeRange = value.timeRangeValuepercentComplete +=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)?) {guardlet task = task as? AVAssetDownloadTaskelse { return }iflet error = error asNSError? {switch (error.domain, error.code) {case (NSURLErrorDomain, NSURLErrorCancelled):print("用户取消")case (NSURLErrorDomain, NSURLErrorUnknown):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) {guardFileManager.default.fileExists(atPath: file.path) else {print("Source file does not exist.")return}do {ifFileManager.default.fileExists(atPath: destinationPath.path) {tryFileManager.default.removeItem(at: destinationPath) //  如果目标路径已经有了同名文件,需要先删除,否则会 move 失败}tryFileManager.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.preferredMediaSelectionguard let task =assetDownloadURLSession.aggregateAssetDownloadTask(with: asset.urlAsset,mediaSelections: [preferredMediaSelection], // 指定希望下载的媒体版本(例如不同的清晰度或语言轨道)assetTitle: asset.stream.name,assetArtworkData: nil,options:[AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 265_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/)。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:http://www.tpcf.cn/bicheng/89597.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

《机器学习数学基础》补充资料:拉格朗日乘子法

瑞士数学家欧拉&#xff08;Leonhard Euler&#xff0c;1707-1783&#xff09;的大名&#xff0c;如雷贯耳——欧拉&#xff0c;是按德文发音翻译。欧拉不仅是公认的十八世纪最伟大的数学家&#xff0c;还是目前史上最多产的数学家。所著的书籍及论文多达 886 部&#xff08;篇…

【PTA数据结构 | C语言版】二叉堆的朴素建堆操作

本专栏持续输出数据结构题目集&#xff0c;欢迎订阅。 文章目录题目代码题目 请编写程序&#xff0c;将 n 个顺序存储的数据用朴素建堆操作调整为最小堆&#xff1b;最后顺次输出堆中元素以检验操作的正确性。 输入格式&#xff1a; 输入首先给出一个正整数 c&#xff08;≤1…

深入解析PyQt5信号与槽的高级玩法:解锁GUI开发新姿势

信号与槽机制是PyQt框架实现组件间通信的核心技术。掌握其高级用法能极大提升开发效率和代码灵活性。本文将通过六大核心模块&#xff0c;结合实战案例&#xff0c;全方位解析信号与槽的进阶使用技巧。自定义信号与槽的完全指南 1. 信号定义规范 class CustomWidget(QWidget):#…

gitee某个分支合并到gitlab目标分支

一、克隆Gitee仓库到本地 git clone https://gitee.com/用户名/仓库名.gitcd 仓库名二、添加 GitLab 仓库作为远程仓库 git remote add gitlab https://gitlab.com/用户名/仓库名.git三、查看所有远程仓库 git remote -v四、拉取 Gitee 上的目标分支 git fetch origin 分支名五…

PyQt5信号与槽(信号与槽的高级玩法)

信号与槽的高级玩法 高级自定义信号与槽 所谓高级自定义信号与槽&#xff0c;指的是我们可以以自己喜欢的方式定义信号与槽函 数&#xff0c;并传递参数。自定义信号的一般流程如下&#xff1a; &#xff08;1&#xff09;定义信号。 &#xff08;2&#xff09;定义槽函数。 &a…

第5天 | openGauss中一个用户可以访问多个数据库

接着昨天继续学习openGauss,今天是第五天了。今天学习内容是使用一个用户访问多个数据库。 老规矩&#xff0c;先登陆墨天轮为我准备的实训实验室 rootmodb:~# su - omm ommmodb:~$ gsql -r创建表空间music_tbs、数据库musicdb10 、用户user10 并赋予 sysadmin权限 omm# CREATE…

Vue3 Anime.js超级炫酷的网页动画库详解

简介 Anime.js 是一个轻量级的 JavaScript 动画库&#xff0c;它提供了简单而强大的 API 来创建各种复杂的动画效果。以下是 Anime.js 的主要使用方法和特性&#xff1a; 安装 npm install animejs 基本用法 <script setup> import { ref, onMounted } from "vu…

苦练Python第18天:Python异常处理锦囊

苦练Python第18天&#xff1a;Python异常处理锦囊 原文链接&#xff1a;https://dev.to/therahul_gupta/day-18100-exception-handling-with-try-except-in-python-3m5a 作者&#xff1a;Rahul Gupta 译者&#xff1a;倔强青铜三 前言 大家好&#xff0c;我是倔强青铜三。是一名…

JVM——如何对java的垃圾回收机制调优?

GC 调优的核心思路就是尽可能的使对象在年轻代被回收&#xff0c;减少对象进入老年代。 具体调优还是得看场景根据 GC 日志具体分析&#xff0c;常见的需要关注的指标是 Young GC 和 Full GC 触发频率、原因、晋升的速率、老年代内存占用量等等。 比如发现频繁会产生 Ful GC&am…

正则表达式使用示例

下面以 Vue&#xff08;前端&#xff09;和 Spring Boot&#xff08;后端&#xff09;为例&#xff0c;展示正则表达式在前后端交互中的应用&#xff0c;以邮箱格式验证为场景&#xff1a;1.前端<template><div class"register-container"><h3>用户…

云端微光,AI启航:低代码开发的智造未来

文章目录前言一、引言&#xff1a;技术浪潮中的个人视角初次体验腾讯云开发 Copilot1.1 低代码的时代机遇1.1.1 为什么低代码如此重要&#xff1f;1.2 AI 的引入&#xff1a;革新的力量1.1.2 Copilot 的亮点1.3 初学者的视角1.3.1 Copilot 带来的改变二、体验记录&#xff1a;云…

图片上传实现

图片上传change函数图片上传图片上传到服务器上传的图片在该页面中显示修改界面代码最终实现效果change函数 这里我们先用输入框控件来举例&#xff1a; 姓名&#xff1a;<input typetext classname>下面我们来写 js 语句&#xff0c;对控件进行绑事件来获取输入框内的…

【PTA数据结构 | C语言版】多叉堆的上下调整

本专栏持续输出数据结构题目集&#xff0c;欢迎订阅。 文章目录题目代码题目 请编写程序&#xff0c;将 n 个已经满足 d 叉最小堆顺序约束的数据直接读入最小堆&#xff1b;随后将下一个读入的数据 x 插入堆&#xff1b;再执行删顶操作并输出删顶的元素&#xff1b;最后顺次输…

selenium后续!!

小项目案例:实现批量下载网页中的资源根据15.3.2小节中的返回网页内容可知,用户只有获取了网页中的图片url才可以将图片下载到*在使用selenium库渲染网页后,可直接通过正则表达式过滤出指定的网页图片&#xff0c;从而实现批量下载接下来以此为思路来实现一个小项目案例。项目任…

深度解析Linux文件I/O三级缓冲体系:用户缓冲区→标准I/O→内核页缓存

在Linux文件I/O操作中&#xff0c;缓冲区的管理是一个核心概念&#xff0c;主要涉及用户空间缓冲区和内核空间缓冲区。理解这两者的区别和工作原理对于高效的文件操作至关重要。 目录 一、什么是缓冲区 二、为什么要引入缓冲区机制 三、三级缓冲体系 1、三级缓冲体系全景图…

【每日算法】专题十三_队列 + 宽搜(bfs)

1. 算法思路 BFS 算法核心思路 BFS&#xff08;广度优先搜索&#xff09;使用 队列&#xff08;Queue&#xff09;按层级顺序遍历图或树的节点。以下是 C 实现的核心思路和代码模板&#xff1a; 算法框架 #include <queue> #include <vector> #include <un…

【动手实验】发送接收窗口对 TCP传输性能的影响

环境准备 服务器信息 两台腾讯云机器 t04&#xff08;172.19.0.4&#xff09;、t11&#xff08;172.19.0.11&#xff09;&#xff0c;系统为 Ubuntu 22.04&#xff0c;内核为 5.15.0-139-generic。默认 RT 在 0.16s 左右。 $ ping 172.19.0.4 PING 172.19.0.4 (172.19.0.4) …

28、鸿蒙Harmony Next开发:不依赖UI组件的全局气泡提示 (openPopup)和不依赖UI组件的全局菜单 (openMenu)、Toast

目录 不依赖UI组件的全局气泡提示 (openPopup) 弹出气泡 创建ComponentContent 绑定组件信息 设置弹出气泡样式 更新气泡样式 关闭气泡 在HAR包中使用全局气泡提示 不依赖UI组件的全局菜单 (openMenu) 弹出菜单 创建ComponentContent 绑定组件信息 设置弹出菜单样…

让老旧医疗设备“听懂”新语言:CAN转EtherCAT的医疗行业应用

在医疗影像设备的智能化升级中&#xff0c;通信协议的兼容性常成为工程师的“痛点”。例如&#xff0c;某医院的移动式X射线机采用CAN协议控制机械臂&#xff0c;而主控系统基于EtherCAT架构。两者协议差异导致数据延迟高达5ms&#xff0c;影像定位精度下降&#xff0c;甚至影响…

ubuntu基础搭建

ubuntu上docker的搭建 https://vulhub.org/zh 网站最下面找到开始使用&#xff0c;有搭建的命令//安装docker&#xff0c;连接失败多试几次 curl -fsSL https://get.docker.com | sh //验证Docker是否正确安装&#xff1a; docker version //还要验证Docker Compose是否可用&am…