使用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.pswp.cn/bicheng/89597.shtml
繁體地址,請注明出處:http://hk.pswp.cn/bicheng/89597.shtml
英文地址,請注明出處:http://en.pswp.cn/bicheng/89597.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋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…