Skip to content
Merged
1 change: 1 addition & 0 deletions Source/Database/Models/OfflineAsset.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public struct OfflineAsset: Hashable {
public var thumbnailURL: String? = nil
public var folderTree: String = ""
public var metadata: [String: Any]? = nil
public var contentProtectionType: ContentProtectionType? = nil

public func hash(into hasher: inout Hasher) {
hasher.combine(assetId)
Expand Down
12 changes: 9 additions & 3 deletions Source/Database/Models/OfflineAssetEntity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class LocalOfflineAsset: Object {
@Persisted var size: Double = 0.0
@Persisted var folderTree: String = ""
@Persisted var drmContentId: String? = nil
@Persisted var contentProtectionType: ContentProtectionType? = nil
@Persisted var metadataMap = Map<String, AnyRealmValue>()
@Persisted var licenseExpiryDate: Date? = nil

Expand Down Expand Up @@ -55,6 +56,7 @@ extension LocalOfflineAsset {
thumbnailURL: String? = nil,
folderTree: String,
drmContentId: String? = nil,
contentProtectionType: ContentProtectionType? = nil,
metadata: [String: Any]? = nil
) -> LocalOfflineAsset {
let localOfflineAsset = LocalOfflineAsset()
Expand All @@ -68,6 +70,7 @@ extension LocalOfflineAsset {
localOfflineAsset.thumbnailURL = thumbnailURL
localOfflineAsset.folderTree = folderTree
localOfflineAsset.drmContentId = drmContentId
localOfflineAsset.contentProtectionType = contentProtectionType
localOfflineAsset.metadata = metadata
return localOfflineAsset
}
Expand All @@ -86,7 +89,8 @@ extension LocalOfflineAsset {
size: self.size,
thumbnailURL: self.thumbnailURL,
folderTree: self.folderTree,
metadata: self.metadata
metadata: self.metadata,
contentProtectionType: self.contentProtectionType
)
}

Expand All @@ -99,11 +103,13 @@ extension LocalOfflineAsset {
let playbackURLString = downloadedFileURL.absoluteString

let video = Video(
id: self.assetId,
playbackURL: playbackURLString,
status: self.status,
drmEncrypted: isDrmEncrypted,
duration: self.duration,
thumbnailURL: self.thumbnailURL
thumbnailURL: self.thumbnailURL,
contentProtectionType: isDrmEncrypted ? .drm : self.contentProtectionType
)

let asset: Asset = Asset(
Expand All @@ -120,7 +126,7 @@ extension LocalOfflineAsset {
}

var downloadedFileURL: URL? {
if !self.downloadedPath.isEmpty{
if !self.downloadedPath.isEmpty {
let baseURL = URL(fileURLWithPath: NSHomeDirectory())
return baseURL.appendingPathComponent(self.downloadedPath)
}
Expand Down
58 changes: 56 additions & 2 deletions Source/Database/TPStreamsDownloadManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public final class TPStreamsDownloadManager {
private var contentKeySession: AVContentKeySession!
private var contentKeyDelegate: ContentKeyDelegate!
private let contentKeyDelegateQueue = DispatchQueue(label: "com.tpstreams.iOSPlayerSDK.ContentKeyDelegateQueueOffline")
private let encryptionKeyDelegate = EncryptionKeyDelegate.shared

private init() {
let backgroundConfiguration = URLSessionConfiguration.background(withIdentifier: "com.tpstreams.downloadSession")
Expand Down Expand Up @@ -195,13 +196,19 @@ public final class TPStreamsDownloadManager {
throw NSError(domain: "TPStreamsSDK", code: -1, userInfo: [NSLocalizedDescriptionKey: "DRM content downloading is not supported in simulator"])
}
#else
contentKeyDelegate.setAssetDetails(asset.id, accessToken, true, offlineLicenseDurationSeconds)
if asset.video?.drmEncrypted == true {
contentKeyDelegate.setAssetDetails(asset.id, accessToken, true, offlineLicenseDurationSeconds)
}
#endif

if LocalOfflineAsset.manager.exists(id: asset.id) {
return
}

if let video = asset.video, video.isAESEncrypted {
EncryptionKeyDelegate.shared.prefetchKey(for: video, identifier: asset.keyIdentifier, accessToken: accessToken)
}

let avUrlAsset = AVURLAsset(url: URL(string: asset.video!.playbackURL)!)

guard let task = assetDownloadURLSession.aggregateAssetDownloadTask(
Expand All @@ -227,6 +234,7 @@ public final class TPStreamsDownloadManager {
thumbnailURL: asset.video!.thumbnailURL ?? "",
folderTree: asset.folderTree ?? "",
drmContentId: asset.drmContentId,
contentProtectionType: asset.video?.contentProtectionType,
metadata: metadata
)
LocalOfflineAsset.manager.add(object: localOfflineAsset)
Expand Down Expand Up @@ -314,6 +322,7 @@ public final class TPStreamsDownloadManager {
}

guard let fileURL = localOfflineAsset.downloadedFileURL else {
deleteEncryptionKey(for: localOfflineAsset)
LocalOfflineAsset.manager.delete(id: assetId)
if hasActiveTask == nil {
tpStreamsDownloadDelegate?.onCanceled(assetId: assetId)
Expand Down Expand Up @@ -368,6 +377,8 @@ public final class TPStreamsDownloadManager {
self.contentKeyDelegate.cleanupPersistentContentKey()
}

self.deleteEncryptionKey(for: localOfflineAsset)

completion(true, nil)
}
} catch {
Expand All @@ -377,6 +388,10 @@ public final class TPStreamsDownloadManager {
}
}
}

internal func deleteEncryptionKey(for localOfflineAsset: LocalOfflineAsset) {
encryptionKeyDelegate.delete(for: localOfflineAsset.assetId)
}

public func getAllOfflineAssets() -> [OfflineAsset] {
// This method retrieves all offline assets from the local storage.
Expand Down Expand Up @@ -424,6 +439,37 @@ public final class TPStreamsDownloadManager {
}
}
}

internal func hardenOfflineManifests(for localOfflineAsset: LocalOfflineAsset) {
guard localOfflineAsset.contentProtectionType == .aes,
let downloadURL = localOfflineAsset.downloadedFileURL else {
return
}

let keyIdentifier: String = localOfflineAsset.assetId

let enumerator = FileManager.default.enumerator(at: downloadURL, includingPropertiesForKeys: nil)

while let fileURL = enumerator?.nextObject() as? URL {
guard fileURL.pathExtension.lowercased() == "m3u8" else { continue }
hardenManifest(at: fileURL, keyIdentifier: keyIdentifier)
}
}

private func hardenManifest(at url: URL, keyIdentifier: String) {
guard let content = try? String(contentsOf: url, encoding: .utf8),
let regex = try? NSRegularExpression(pattern: "#EXT-X-KEY:METHOD=AES-128,URI=\"([^\"]+)\""),
let match = regex.firstMatch(in: content, range: NSRange(content.startIndex..., in: content)) else {
return
}

let fullMatchRange = Range(match.range, in: content)!
let uriRange = Range(match.range(at: 1), in: content)!
let originalURI = String(content[uriRange])

let hardenedContent = content.replacingCharacters(in: fullMatchRange, with: "#EXT-X-KEY:METHOD=AES-128,URI=\"tpkey://\(keyIdentifier)\"")
try? hardenedContent.write(to: url, atomically: true, encoding: .utf8)
}
}

internal class AssetDownloadDelegate: NSObject, AVAssetDownloadDelegate {
Expand Down Expand Up @@ -469,14 +515,22 @@ internal class AssetDownloadDelegate: NSObject, AVAssetDownloadDelegate {
return .failed
}
}()

if status == .failed || status == .deleted {
TPStreamsDownloadManager.shared.deleteEncryptionKey(for: localOfflineAsset)
}

let updateValues: [String: Any] = ["status": status.rawValue, "downloadedAt": Date()]
LocalOfflineAsset.manager.update(object: localOfflineAsset, with: updateValues)
if status == Status.deleted {
if status == .deleted {
tpStreamsDownloadDelegate?.onCanceled(assetId: localOfflineAsset.assetId)
} else if status == Status.failed {
tpStreamsDownloadDelegate?.onFailed(offlineAsset: localOfflineAsset.asOfflineAsset(), error: error)
tpStreamsDownloadDelegate?.onStateChange(status: status, offlineAsset: localOfflineAsset.asOfflineAsset())
} else {
if status == .finished {
TPStreamsDownloadManager.shared.hardenOfflineManifests(for: localOfflineAsset)
}
tpStreamsDownloadDelegate?.onComplete(offlineAsset: localOfflineAsset.asOfflineAsset())
tpStreamsDownloadDelegate?.onStateChange(status: status, offlineAsset: localOfflineAsset.asOfflineAsset())
}
Expand Down
133 changes: 133 additions & 0 deletions Source/Managers/EncryptionKeyDelegate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import Foundation
import Alamofire

final class EncryptionKeyDelegate {
static let shared = EncryptionKeyDelegate()

private let serviceId = "com.tpstreams.iOSPlayerSDK.encryption.keys"
private let keyPrefix = "VIDEO_ENCRYPTION_KEY_"

private init() {}

private func keychainKey(for identifier: String) -> String {
return keyPrefix + identifier
}

func save(encryptionKey: Data, for identifier: String) {
KeychainUtil.save(data: encryptionKey, service: serviceId, account: keychainKey(for: identifier))
}

func get(for identifier: String) -> Data? {
return KeychainUtil.get(service: serviceId, account: keychainKey(for: identifier))
}

func delete(for identifier: String) {
KeychainUtil.delete(service: serviceId, account: keychainKey(for: identifier))
}

func deleteAll() {
KeychainUtil.deleteAll(service: serviceId)
}

func prefetchKey(for video: Video, identifier: String, accessToken: String?) {
fetchKey(assetId: identifier, accessToken: accessToken) { [weak self] keyData in
if let keyData = keyData {
self?.save(encryptionKey: keyData, for: identifier)
} else if let url = URL(string: video.playbackURL) {
self?.parsePlaylist(url, identifier: identifier, accessToken: accessToken)
}
}
}

private func parsePlaylist(_ playlistURL: URL, identifier: String, accessToken: String?) {
let headers = makeAuthHeaders(accessToken: accessToken)
var finalURL = addTokenToURL(playlistURL, accessToken: accessToken) ?? playlistURL

AF.request(finalURL, headers: headers).responseData { [weak self] response in
guard let data = response.data,
let content = String(data: data, encoding: .utf8) else { return }

if content.contains("#EXT-X-STREAM-INF") {
self?.handleVariantPlaylist(content, baseURL: playlistURL, identifier: identifier, accessToken: accessToken)
return
}

self?.extractAndSaveKey(from: content, identifier: identifier, baseURL: playlistURL, accessToken: accessToken)
}
}

private func handleVariantPlaylist(_ content: String, baseURL: URL, identifier: String, accessToken: String?) {
guard let variantLine = content.components(separatedBy: .newlines)
.first(where: { $0.contains(".m3u8") && !$0.hasPrefix("#") }),
let variantURL = URL(string: variantLine.trimmingCharacters(in: .whitespaces), relativeTo: baseURL) else {
return
}
parsePlaylist(variantURL, identifier: identifier, accessToken: accessToken)
}

private func extractAndSaveKey(from content: String, identifier: String, baseURL: URL, accessToken: String?) {
guard let keyURL = extractKeyURL(from: content, baseURL: baseURL) else { return }

fetchKey(url: keyURL, accessToken: accessToken) { [weak self] data in
if let data = data {
self?.save(encryptionKey: data, for: identifier)
}
}
}

private func extractKeyURL(from content: String, baseURL: URL) -> URL? {
let pattern = "#EXT-X-KEY:METHOD=AES-128,URI=\"([^\"]+)\""
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: content, range: NSRange(content.startIndex..., in: content)),
let uriRange = Range(match.range(at: 1), in: content) else {
return nil
}
let uri = String(content[uriRange])
return URL(string: uri, relativeTo: baseURL)
}

func fetchKey(assetId: String? = nil, url: URL? = nil, accessToken: String?, completion: @escaping (Data?) -> Void) {
guard let requestURL = buildRequestURL(assetId: assetId, url: url) else {
completion(nil)
return
}

var finalURL = addTokenToURL(requestURL, accessToken: accessToken) ?? requestURL
let headers = makeAuthHeaders(accessToken: accessToken)

AF.request(finalURL, headers: headers).responseData { response in
completion(try? response.result.get())
}
}

private func buildRequestURL(assetId: String?, url: URL?) -> URL? {
if let url = url { return url }

guard let assetId = assetId,
let org = TPStreamsSDK.orgCode else { return nil }

let template = TPStreamsSDK.provider.API.AES_ENCRYPTION_KEY_API
return URL(string: String(format: template, org, assetId))
}

private func addTokenToURL(_ url: URL, accessToken: String?) -> URL? {
guard let token = accessToken, !token.isEmpty,
var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
return url
}

var items = components.queryItems ?? []
if !items.contains(where: { $0.name == "access_token" }) {
items.append(URLQueryItem(name: "access_token", value: token))
components.queryItems = items
}
return components.url
}

private func makeAuthHeaders(accessToken: String?) -> HTTPHeaders {
if let token = TPStreamsSDK.authToken {
return ["Authorization": "JWT \(token)"]
}
return [:]
}
}
Loading
Loading