Skip to content

Commit

Permalink
Add DRM protected content playback support (#148)
Browse files Browse the repository at this point in the history
  • Loading branch information
defagos authored Nov 21, 2022
1 parent b6cf666 commit fc3a534
Show file tree
Hide file tree
Showing 14 changed files with 574 additions and 41 deletions.
10 changes: 10 additions & 0 deletions Demo/Sources/ExamplesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,16 @@ private extension ExamplesView {
description: "Test 9:16",
media: MediaURN.onDemandVerticalVideo
),
Example(
title: "Video livestream - HLS (URN)",
description: "SRF 1",
media: MediaURN.liveVideo
),
Example(
title: "Video livestream with DVR - HLS (URN)",
description: "RTS 1",
media: MediaURN.dvrVideo
),
Example(
title: "Audio livestream with DVR - HLS (URN)",
description: "Couleur 3 en direct",
Expand Down
3 changes: 3 additions & 0 deletions Demo/Sources/Media.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ enum MediaURN {
static let onDemandSquareVideo = Media.urn("urn:rts:video:8393241")
static let onDemandVerticalVideo = Media.urn("urn:rts:video:8412286")

static let liveVideo = Media.urn("urn:srf:video:c4927fcf-e1a0-0001-7edd-1ef01d441651")
static let dvrVideo = Media.urn("urn:rts:video:3608506")

static let dvrAudio = Media.urn("urn:rts:audio:3262363")
static let onDemandAudio = Media.urn("urn:rsi:audio:8833144")

Expand Down
93 changes: 93 additions & 0 deletions Sources/CoreBusiness/ContentKeySessionDelegate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
//
// Copyright (c) SRG SSR. All rights reserved.
//
// License information is available from the LICENSE file.
//

import AVFoundation
import Combine
import Player

final class ContentKeySessionDelegate: NSObject, AVContentKeySessionDelegate {
private let certificateUrl: URL
private let session = URLSession(configuration: .default)
private var cancellable: AnyCancellable?

init(certificateUrl: URL) {
self.certificateUrl = certificateUrl
}

private static func contentKeyRequestDataPublisher(
for request: AVContentKeyRequest,
certificateData: Data
) -> AnyPublisher<Data, Error> {
Future { promise in
// Use a dummy content identifier (otherwise the request will fail).
request.makeStreamingContentKeyRequestData(
forApp: certificateData,
contentIdentifier: "content_id".data(using: .utf8)
) { data, error in
if let data {
promise(.success(data))
}
else if let error {
promise(.failure(error))
}
}
}
.eraseToAnyPublisher()
}

private static func contentKeyContextRequest(from identifier: Any?, httpBody: Data) -> URLRequest? {
guard let skdUrlString = identifier as? String,
var components = URLComponents(string: skdUrlString) else {
return nil
}

components.scheme = "https"
guard let url = components.url else { return nil }

var request = URLRequest(url: url)
request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
request.httpMethod = "POST"
request.httpBody = httpBody
return request
}

private static func contentKeyContextDataPublisher(
fromKeyRequestData keyRequestData: Data,
identifier: Any?,
session: URLSession
) -> AnyPublisher<Data, Error> {
guard let request = contentKeyContextRequest(from: identifier, httpBody: keyRequestData) else {
return Fail(error: DRMError.missingContentKeyContext)
.eraseToAnyPublisher()
}
return session.dataTaskPublisher(for: request)
.mapError { $0 }
.map(\.data)
.eraseToAnyPublisher()
}

func contentKeySession(_ session: AVContentKeySession, didProvide keyRequest: AVContentKeyRequest) {
cancellable = self.session.dataTaskPublisher(for: certificateUrl)
.mapError { $0 }
.map { Self.contentKeyRequestDataPublisher(for: keyRequest, certificateData: $0.data) }
.switchToLatest()
.map { [session = self.session] data in
Self.contentKeyContextDataPublisher(fromKeyRequestData: data, identifier: keyRequest.identifier, session: session)
}
.switchToLatest()
.sink { completion in
switch completion {
case .finished:
break
case let .failure(error):
keyRequest.processContentKeyResponseErrorReliably(error)
}
} receiveValue: { data in
let response = AVContentKeyResponse(fairPlayStreamingKeyResponseData: data)
keyRequest.processContentKeyResponse(response)
}
}
}
19 changes: 19 additions & 0 deletions Sources/CoreBusiness/DRM.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//
// Copyright (c) SRG SSR. All rights reserved.
//
// License information is available from the LICENSE file.
//

import Foundation

struct DRM: Decodable {
enum `Type`: String, Decodable {
case fairPlay = "FAIRPLAY"
case playReady = "PLAYREADY"
case widevine = "WIDEVINE"
}

let type: `Type`
let certificateUrl: URL?
let licenseUrl: URL
}
8 changes: 8 additions & 0 deletions Sources/CoreBusiness/Errors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@

import Foundation

struct DRMError: LocalizedError {
static var missingContentKeyContext: Self {
DRMError(errorDescription: "Could not retrieve DRM license")
}

let errorDescription: String?
}

enum TokenError: Error {
case malformedParameters
}
Expand Down
48 changes: 24 additions & 24 deletions Sources/CoreBusiness/PlayerItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,34 +17,13 @@ public extension PlayerItem {
convenience init(urn: String, automaticallyLoadedAssetKeys: [String]? = nil, environment: Environment = .production) {
// swiftlint:disable:previous discouraged_optional_collection
let publisher = DataProvider(environment: environment).recommendedPlayableResource(forUrn: urn)
.map { Self.playerItem(for: $0, automaticallyLoadedAssetKeys: automaticallyLoadedAssetKeys) }
.map { Self.configuredPlayerItem(for: $0, automaticallyLoadedAssetKeys: automaticallyLoadedAssetKeys) }
self.init(publisher: publisher)
}

private static func url(for resource: Resource) -> URL {
switch resource.tokenType {
case .akamai:
return AkamaiURLCoding.encodeUrl(resource.url)
default:
return resource.url
}
}

private static func resourceLoaderDelegate(for resource: Resource) -> AVAssetResourceLoaderDelegate? {
switch resource.tokenType {
case .akamai:
return AkamaiResourceLoaderDelegate()
default:
return nil
}
}

private static func playerItem(for resource: Resource, automaticallyLoadedAssetKeys: [String]?) -> AVPlayerItem {
private static func configuredPlayerItem(for resource: Resource, automaticallyLoadedAssetKeys: [String]?) -> AVPlayerItem {
// swiftlint:disable:previous discouraged_optional_collection
let item = AVPlayerItem.loading(
url: url(for: resource),
resourceLoaderDelegate: resourceLoaderDelegate(for: resource)
)
let item = playerItem(for: resource, automaticallyLoadedAssetKeys: automaticallyLoadedAssetKeys)
if resource.streamType == .live {
/// Limit buffering and force the player to return to the live edge when re-buffering. This ensures
/// livestreams cannot be paused and resumed in the past, as requested by business people.
Expand All @@ -53,4 +32,25 @@ public extension PlayerItem {
}
return item
}

private static func playerItem(for resource: Resource, automaticallyLoadedAssetKeys: [String]?) -> AVPlayerItem {
// swiftlint:disable:previous discouraged_optional_collection
if let certificateUrl = resource.drms?.first(where: { $0.type == .fairPlay })?.certificateUrl {
return AVPlayerItem.loading(
url: resource.url,
contentKeySessionDelegate: ContentKeySessionDelegate(certificateUrl: certificateUrl)
)
}
else {
switch resource.tokenType {
case .akamai:
return AVPlayerItem.loading(
url: AkamaiURLCoding.encodeUrl(resource.url),
resourceLoaderDelegate: AkamaiResourceLoaderDelegate()
)
default:
return AVPlayerItem(url: resource.url)
}
}
}
}
4 changes: 4 additions & 0 deletions Sources/CoreBusiness/Resource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Player

struct Resource: Decodable {
enum CodingKeys: String, CodingKey {
case drms = "drmList"
case isDvr = "dvr"
case isLive = "live"
case streamingMethod = "streaming"
Expand All @@ -33,6 +34,9 @@ struct Resource: Decodable {

let tokenType: TokenType

// swiftlint:disable:next discouraged_optional_collection
let drms: [DRM]?

private let isDvr: Bool
private let isLive: Bool
}
22 changes: 22 additions & 0 deletions Sources/Player/ContentKeyRequest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// Copyright (c) SRG SSR. All rights reserved.
//
// License information is available from the LICENSE file.
//

import AVFoundation

public extension AVContentKeyRequest {
/// Informs the receiver that obtaining a content key response has failed, resulting in failure handling. Unlike
/// `processContentKeyResponseError(_:)` this method ensures error information can be reliably forwarded to the
/// player item being loaded in case of failure.
/// - Parameter error: An error object indicating the reason for the failure.
func processContentKeyResponseErrorReliably(_ error: Error) {
if let nsError = NSError.error(from: error) {
processContentKeyResponseError(nsError)
}
else {
processContentKeyResponseError(error)
}
}
}
71 changes: 61 additions & 10 deletions Sources/Player/PlayerItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,26 @@ final class ResourceLoadedPlayerItem: AVPlayerItem {
}
}

final class ContentKeySessionPlayerItem: AVPlayerItem {
private let contentKeySession = AVContentKeySession(keySystem: .fairPlayStreaming)
private let contentKeySessionDelegate: AVContentKeySessionDelegate

init(url: URL, contentKeySessionDelegate: AVContentKeySessionDelegate, automaticallyLoadedAssetKeys: [String]?) {
// swiftlint:disable:previous discouraged_optional_collection
self.contentKeySessionDelegate = contentKeySessionDelegate

let asset = AVURLAsset(url: url)
super.init(asset: asset, automaticallyLoadedAssetKeys: automaticallyLoadedAssetKeys)

contentKeySession.setDelegate(
contentKeySessionDelegate,
queue: DispatchQueue(label: "ch.srgssr.player.content_key_session")
)
contentKeySession.addContentKeyRecipient(asset)
contentKeySession.processContentKeyRequest(withIdentifier: nil, initializationData: nil)
}
}

/// An item to be inserted into the player.
public final class PlayerItem: Equatable {
@Published var playerItem = AVPlayerItem.loading
Expand Down Expand Up @@ -87,27 +107,58 @@ public extension AVPlayerItem {
return ResourceLoadedPlayerItem(url: url, resourceLoaderDelegate: FailingResourceLoaderDelegate(error: error), automaticallyLoadedAssetKeys: nil)
}

/// An item which loads the specified URL (with an optionally associated resource loader delegate).
/// Create a player item from a URL.
/// - Parameters:
/// - url: The URL to play.
/// - automaticallyLoadedAssetKeys: The asset keys to load before the item is ready to play.
static func loading(
url: URL,
resourceLoaderDelegate: AVAssetResourceLoaderDelegate? = nil,
automaticallyLoadedAssetKeys: [String]? = nil
// swiftlint:disable:previous discouraged_optional_collection
) -> AVPlayerItem {
if let resourceLoaderDelegate {
return ResourceLoadedPlayerItem(
url: url,
resourceLoaderDelegate: resourceLoaderDelegate,
automaticallyLoadedAssetKeys: automaticallyLoadedAssetKeys
)
}
else if let automaticallyLoadedAssetKeys {
if let automaticallyLoadedAssetKeys {
return AVPlayerItem(url: url, automaticallyLoadedAssetKeys: automaticallyLoadedAssetKeys)
}
else {
return AVPlayerItem(url: url)
}
}

/// An item which loads the specified URL (with an optionally associated resource loader delegate).
/// - Parameters:
/// - url: The URL to play.
/// - resourceLoaderDelegate: The resource loader delegate to use (automatically retained).
/// - automaticallyLoadedAssetKeys: The asset keys to load before the item is ready to play.
static func loading(
url: URL,
resourceLoaderDelegate: AVAssetResourceLoaderDelegate,
automaticallyLoadedAssetKeys: [String]? = nil
// swiftlint:disable:previous discouraged_optional_collection
) -> AVPlayerItem {
ResourceLoadedPlayerItem(
url: url,
resourceLoaderDelegate: resourceLoaderDelegate,
automaticallyLoadedAssetKeys: automaticallyLoadedAssetKeys
)
}

/// An item which loads the specified URL (with an optionally associated content key session delegate).
/// - Parameters:
/// - url: The URL to play.
/// - contentKeySessionDelegate: The content key session delegate (automatically retained).
/// - automaticallyLoadedAssetKeys: The asset keys to load before the item is ready to play.
static func loading(
url: URL,
contentKeySessionDelegate: AVContentKeySessionDelegate,
automaticallyLoadedAssetKeys: [String]? = nil
// swiftlint:disable:previous discouraged_optional_collection
) -> AVPlayerItem {
ContentKeySessionPlayerItem(
url: url,
contentKeySessionDelegate: contentKeySessionDelegate,
automaticallyLoadedAssetKeys: automaticallyLoadedAssetKeys
)
}
}

public extension PlayerItem {
Expand Down
7 changes: 1 addition & 6 deletions Sources/Player/PlayerPublishers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,10 @@ extension AVPlayer {
.eraseToAnyPublisher()
}

func ratePublisher() -> AnyPublisher<Float, Never> {
publisher(for: \.rate)
.eraseToAnyPublisher()
}

func playbackStatePublisher() -> AnyPublisher<PlaybackState, Never> {
Publishers.CombineLatest(
currentItemStatePublisher(),
ratePublisher()
publisher(for: \.rate)
)
.map { PlaybackState.state(for: $0, rate: $1) }
.removeDuplicates()
Expand Down
1 change: 1 addition & 0 deletions Tests/CoreBusinessTests/Mock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ enum Mock {
}

enum MediaCompositionKind: String {
case drm
case onDemand
case live
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"data" : [
{
"filename" : "MediaComposition_drm.json",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading

0 comments on commit fc3a534

Please sign in to comment.