01
DRM 介绍
DRM,即数字版权管理(Digital Rights Management),是指使用加密技术保护视频内容、通过专业技术安全地存储和传输密钥(加密密钥和解密密钥)、并允许内容生产商设置商业规则,限制内容观看者的一种系统。
常见的 DRM 方案有下面几种,其中在 Apple 平台上,使用 FairPlay 方案:
FairPlay 支持的协议:
我们采用的是 HLS + fmp4 的方案。
FairPlay 支持的平台和系统要求:
.m3u8
播放地址给到 AVPlayer;m3u8
清单文件,发现 #EXT-X-KEY
,表明这是一个被加密的视频;名词解释
.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 官方文档中裁出来的一张时序图:
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 的密钥。
AVAssetResourceLoader
AVContentKeySession
这种方式播放视频,只能在用户点击播放后,播放流程过程中去请求密钥。
具体的使用方式如下:
[self.urlAsset resourceLoader]
获取 AVAssetResourceLoader
对象,并设置代理 [[self.urlAsset resourceLoader] setDelegate:loaderDelegate queue:globalNotificationQueue()];
;AVAssetResourceLoaderDelegate
的类,实现其中的 resourceLoader: shouldWaitForRenewalOfRequestedResource:
方法;[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 = {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", (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
就是在苹果开发者后台下载下来的证书文件:
苹果还有第二种管理密钥的方式 AVContentKeySession
,这个 API 是于 2017 年首次公布的,相比 AVAssetResourceLoader
,它可以更好的管理秘钥,并且和视频播放过程进行解耦。
开发者可以根据用户的行为,提前下载请求即将要播放的视频的密钥信息,以加快视频的起播速度(苹果官方称之为 prewraming)。
AVContentKeySession
还支持播放离线下来的 FairPlay 视频(这个我们后面的内容中会提到)。
AVContentKeySession
的使用方法简单介绍// 创建 session
// 用户 AVContentKeySessionDelegate 代理方法回调的线程
_keyQueue = dispatch_queue_create("com.sohuvideo.contentkeyqueue", DISPATCH_QUEUE_SERIAL);
self.keySession = [AVContentKeySession contentKeySessionWithKeySystem:AVContentKeySystemFairPlayStreaming];
[self.keySession setDelegate:self queue:_keyQueue];
#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 {
}
[self.keySession addContentKeyRecipient:recipient];
下面分三个场景来具体介绍 AVContentKeySession 的使用
这个场景,类似使用 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]; // 通知系统请求秘钥信息成功,可以继续后续播放流程
}
}
这种情况是开发者可以根据用户行为,来预测即将播放的视频(例如预测用户会继续播放下一剧集),提前将该视频的解密秘钥获取下来,以便后续播放。
// 在合适时机,主动调用 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]
这种情况也需要开发者在下载任务开始之前,主动调用 processContentKeyRequestWithIdentifier:initializationData:options
,不同点在于需要在 session delegate 回调方法里请求 persistable key,并将其存储下来。
respondByRequestingPersistableContentKeyRequestAndReturnError:
;[contentKey writeToURL:fileUrl options:NSDataWritingAtomic error:&err]
;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
}
上面有提过存储期和播放期两个概念的过期时间,具体如下:
存储期 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 已过期,需重新请求
}
}
}
}
}
播放本地路径下的视频,创建 NSURL
时候需要使用 fileURLWithPath
:
// 不用 NSURL.init(string: <#T##String#>)
let fileUrl = NSURL.fileURL(withPath: "/Library/Caches/aHR0cDovLzEwLjI==_E0363AAE664D0C7E.movpkg")
let urlAsset = AVAsset.init(url: fileUrl)
关于是否使用单例来管理 AVContentKeySession
的讨论,详细可以见论坛这里(https://forums.developer.apple.com/forums/thread/108708):
@interface SofaAVContentKeyManager ()<AVContentKeySessionDelegate>
@property (nonatomic, strong, readwrite) AVContentKeySession *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
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())
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()
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 as? AVAssetDownloadTask else { return }
if let error = error as NSError? {
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")
}
}
// 下载任务完成回调
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)")
}
}
mp4 和 hls 视频下载完成后的文件都是以为 movpkg
结尾的,但是使用 AVPlayer 无法播放 mp4.movpkg
。只有 HLS 视频下载后的文件 hls.movpkg
是一个 bundle
,可以使用AVPlayer/AVPlayerViewController
播放。
如果下载的是一个 MP4 视频,可以在下载结束调用 move(file: URL, to destinationPath: URL)
时候,把 destinationPath
设置为一个 xxx.mp4
结尾的路径,这样后续可以正常播放这个 xxx.mp4
如果你的 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:
[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
参考链接
(https://devstreaming-cdn.apple.com/videos/wwdc/2018/507axjplrd0yjzixfz/507/507_hd_avcontentkeysession_best_practices.mp4?dl=1);