Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Added

- Uplynk
- Added `orderedPreplayParameters` to `UplynkSSAIConfiguration`, which can be used to maintain the order of preplay parameters when making a request.

### Fixed

- Uplynk
- Improved URL encoding for characters such as `%`, `&`, `=`, `+` and `,` to preserve pre-encoded values and prevent server-side double decoding issues.

### Changed

- Uplynk
- When ping feature is not configured, the player will now send an empty string instead of `"&ad.pingc=0"` to prevent unsigned parameters that could break signature validation.
- Deprecated the old `preplayParameters` in favor of the new `orderedPreplayParameters`

## [10.8.0.1] - 2026-01-20

### Fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,31 +9,26 @@
import Foundation

extension UplynkSSAIConfiguration {
var drmParameters: String {
contentProtected ? "&manifest=m3u8&rmt=fps" : ""
}

var urlParameters: String {
guard !preplayParameters.isEmpty else {
return ""
}
let joinedParameters = preplayParameters.map {
"\($0.key)=\($0.value)"
}.joined(separator: "&")

return "&\(joinedParameters)"
var drmParameters: [URLQueryItem] {
guard contentProtected else {return []}
return [
URLQueryItem(name: "manifest", value: "m3u8"),
URLQueryItem(name: "rmt", value: "fps")
]
}

var pingFeature: UplynkPingFeature {
UplynkPingFeature(ssaiConfiguration: self)
}

var pingParameters: String {
var pingParameters: [URLQueryItem] {
let pingFeature = pingFeature
if pingFeature == .noPing {
return "&ad.pingc=0"
} else {
return "&ad.pingc=1&ad.pingf=\(pingFeature.rawValue)"
if pingFeature == .noPing { return [] }
else {
return [
URLQueryItem(name: "ad.cping", value: "1"),
URLQueryItem(name: "ad.pingf", value: pingFeature.rawValue.description)
]
}
}

Expand Down
30 changes: 25 additions & 5 deletions Code/Uplynk/Source/Internal/UplynkSSAIURLBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Foundation

class UplynkSSAIURLBuilder {
private static let DEFAULT_PREFIX: String = "https://content.uplynk.com"
private static let versionQueryItem = CollectionOfOne(URLQueryItem(name: "v", value: "2"))

private let ssaiConfiguration: UplynkSSAIConfiguration

Expand All @@ -22,17 +23,24 @@ class UplynkSSAIURLBuilder {

private var urlAssetType: String { ssaiConfiguration.urlAssetType }
private var urlAssetID: String { ssaiConfiguration.urlAssetID }
private var drmParameters: String { ssaiConfiguration.drmParameters }
private var pingParameters: String { ssaiConfiguration.pingParameters }
private var urlParameters: String { ssaiConfiguration.urlParameters }
private var drmParameters: [URLQueryItem] { ssaiConfiguration.drmParameters }
private var pingParameters: [URLQueryItem] { ssaiConfiguration.pingParameters }
private var preplayParameters: [URLQueryItem] { ssaiConfiguration.orderedPreplayParameters.map(URLQueryItem.init) }
private var id: UplynkSSAIConfiguration.ID { ssaiConfiguration.id }

var queryString: String {
let items = Self.versionQueryItem + drmParameters + pingParameters + preplayParameters
var urlBuilder = URLComponents()
urlBuilder.percentEncodedQueryItems = items.map(\.encodedForUplynk)
return urlBuilder.percentEncodedQuery!
}

func buildPreplayVODURL() -> String {
return "\(prefix)/preplay/\(urlAssetID)?v=2\(drmParameters)\(pingParameters)\(urlParameters)"
return "\(prefix)/preplay/\(urlAssetID)?\(queryString)"
}

func buildPreplayLiveURL() -> String {
return "\(prefix)/preplay/\(urlAssetType)/\(urlAssetID)?v=2\(drmParameters)\(pingParameters)\(urlParameters)"
return "\(prefix)/preplay/\(urlAssetType)/\(urlAssetID)?\(queryString)"
}

func buildAssetInfoURLs(
Expand Down Expand Up @@ -81,3 +89,15 @@ class UplynkSSAIURLBuilder {
"\(prefix)/session/ping/\(sessionID).json?v=3&pt=\(currentTimeSeconds)"
}
}

extension CharacterSet {
fileprivate static let uplynkUrlQueryValueAllowed = CharacterSet.urlQueryAllowed.subtracting(CharacterSet(charactersIn: "&+=?,"))
}
extension URLQueryItem {
fileprivate var encodedForUplynk: URLQueryItem {
URLQueryItem(
name: name,
value: value?.addingPercentEncoding(withAllowedCharacters: .uplynkUrlQueryValueAllowed)
)
}
}
30 changes: 28 additions & 2 deletions Code/Uplynk/Source/UplynkSSAIConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,14 @@ public class UplynkSSAIConfiguration: CustomServerSideAdInsertionConfiguration {

public let id: ID
public let prefix: String?
public let preplayParameters: [String: String]
public let orderedPreplayParameters: [(String, String)]
public let assetType: AssetType
public let contentProtected: Bool
public let assetInfo: Bool
public let pingConfiguration: UplynkPingConfiguration
public let playbackURLParameters: [(String, String)]

@available(*, deprecated, message: "Use the initializer with orderedPreplayParameters instead.")
public init(
id: ID,
assetType: AssetType,
Expand All @@ -46,10 +47,35 @@ public class UplynkSSAIConfiguration: CustomServerSideAdInsertionConfiguration {
self.id = id
self.assetType = assetType
self.prefix = prefix
self.preplayParameters = preplayParameters
self.orderedPreplayParameters = Array(preplayParameters)
self.contentProtected = contentProtected
self.assetInfo = assetInfo
self.pingConfiguration = uplynkPingConfiguration
self.playbackURLParameters = playbackURLParameters
}

public init(
id: ID,
assetType: AssetType,
orderedPreplayParameters: [(String, String)],
prefix: String? = nil,
contentProtected: Bool = false,
assetInfo: Bool = false,
uplynkPingConfiguration: UplynkPingConfiguration = .init(),
playbackURLParameters: [(String, String)] = []
) {
self.id = id
self.assetType = assetType
self.orderedPreplayParameters = orderedPreplayParameters
self.prefix = prefix
self.contentProtected = contentProtected
self.assetInfo = assetInfo
self.pingConfiguration = uplynkPingConfiguration
self.playbackURLParameters = playbackURLParameters
}

@available(*, deprecated, renamed: "orderedPreplayParameters", message: "Passing preplayParameters as a dictionary is no longer supported. Use orderedPreplayParameters instead.")
var preplayParameters: [String: String] {
Dictionary(orderedPreplayParameters) { left, right in left }
}
}
2 changes: 2 additions & 0 deletions Code/Uplynk/Tests/Mocks/MockAdBreak.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Foundation
import THEOplayerSDK

struct MockAdBreak: AdBreak {
let id: String? = UUID().uuidString
var ads: [Ad] = []
var maxDuration: Int = 0
var maxRemainingDuration: Double = 0
Expand All @@ -18,6 +19,7 @@ struct MockAdBreak: AdBreak {
}

struct MockAd: Ad {
let isSlate = false
var adBreak: AdBreak = MockAdBreak()
var companions: [CompanionAd] = []
var type: String = ""
Expand Down
165 changes: 162 additions & 3 deletions Code/Uplynk/Tests/UplynkSSAIConfigurationURLBuilderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,7 @@ final class UplynkSSAIConfigurationURLBuilderTests: XCTestCase {
let prefix = "https://content.uplynk.com"
let assetID = "a123"

let validNoPingQueryParameter = "ad.pingc=0"
let validPingQueryParameter = "ad.pingc=1&ad.pingf=\(pingFeature.rawValue)"
let validPingQueryParameter = "ad.cping=1&ad.pingf=\(pingFeature.rawValue)"

let pingConfiguration = switch pingFeature {
case .noPing:
Expand All @@ -89,7 +88,7 @@ final class UplynkSSAIConfigurationURLBuilderTests: XCTestCase {
let builtPreplayURL = UplynkSSAIURLBuilder(ssaiConfiguration: configurationWithAssetID).buildPreplayVODURL()
switch (pingFeature) {
case .noPing:
XCTAssertTrue(builtPreplayURL.contains(validNoPingQueryParameter))
XCTAssertFalse(builtPreplayURL.contains(validPingQueryParameter), "built PreplayURL should not contain the ping query parameter \(validPingQueryParameter). url: \(builtPreplayURL)")
default:
XCTAssertTrue(builtPreplayURL.contains(validPingQueryParameter))

Expand Down Expand Up @@ -139,4 +138,164 @@ final class UplynkSSAIConfigurationURLBuilderTests: XCTestCase {
XCTAssertTrue(builtPreplayURL.contains(validPrePlayParameters) ||
builtPreplayURL.contains(anotherValidPrePlayParameters))
}

/// Makes sure Uplynk URLs do not contain the sequence "?&"
/// FIFA raised an issue that the Uplynk backend does not support those kind of URLs.
/// See [THEOSD-16266] [OPTI-1771]
func testEmptyQueryParameter() {
let hasEmptyParameter: (String)->Bool = { $0.contains("?&") }
let faultyURL = "https://content.uplynk.com/preplay/ID?&sig=signature"
XCTAssert(hasEmptyParameter(faultyURL))

let assetID = "a123"
let vodBuilder = UplynkSSAIURLBuilder(
ssaiConfiguration: UplynkSSAIConfiguration(
id: .asset(ids: [assetID]),
assetType: .asset
)
)
let liveBuilder = UplynkSSAIURLBuilder(
ssaiConfiguration: UplynkSSAIConfiguration(
id: .asset(ids: [assetID]),
assetType: .channel
)
)

let preplayVod = vodBuilder.buildPreplayVODURL()
let preplayFaultyLive = vodBuilder.buildPreplayLiveURL()
print(preplayVod)
XCTAssertFalse(hasEmptyParameter(preplayVod))
print(preplayFaultyLive)
XCTAssertFalse(hasEmptyParameter(preplayFaultyLive))

let preplayFaultyVod = liveBuilder.buildPreplayVODURL()
let preplayLive = liveBuilder.buildPreplayLiveURL()
print(preplayFaultyVod)
XCTAssertFalse(hasEmptyParameter(preplayFaultyVod))
print(preplayLive)
XCTAssertFalse(hasEmptyParameter(preplayLive))
}

func testPreplayArray() {
let normalParameter = ("keyA", "valueA")
let specialParameter = ("special","?&+=,%")
let specialEncodedParameter = (specialParameter.0, "%3F%26%2B%3D%2C%25")
let mixedParameters = [normalParameter, specialParameter]
let mixedEncodedParamteres = [normalParameter, specialEncodedParameter]

let configs: [TestConfig] = [
TestConfig(assetType: .asset, preplayArray: [normalParameter], expectedParams: [normalParameter]),
TestConfig(assetType: .asset, preplayArray: [specialParameter], expectedParams: [specialEncodedParameter]),
TestConfig(assetType: .asset, preplayArray: mixedParameters, expectedParams: mixedEncodedParamteres),
TestConfig(assetType: .channel, preplayArray: [normalParameter], expectedParams: [normalParameter]),
TestConfig(assetType: .channel, preplayArray: [specialParameter], expectedParams: [specialEncodedParameter]),
TestConfig(assetType: .channel, preplayArray: mixedParameters, expectedParams: mixedEncodedParamteres)
]

for config in configs {
config.assertUrlContainsPreplayParams()
}

let emptyParams = TestConfig(assetType: .asset, preplayArray: [], expectedParams: [])
XCTAssertFalse(emptyParams.url.contains("&"), "A config without params should not contain an `&` character")
}

func testPreplayParameterOrdering() {
let preplayParams = (0..<20).map { index in
("key\(index)", "value\(index)")
}
let vod = TestConfig(assetType: .asset, preplayArray: preplayParams, expectedParams: []).url

var reversedOrder = Array(preplayParams.reversed())
var lastIndex = vod.startIndex
while let item = reversedOrder.popLast() {
if let range = vod.range(of: item.0) {
let newIndex = range.upperBound
if newIndex < lastIndex {
XCTFail("Parameter \(item.0) is out of order. ExpectedOrder: \(preplayParams.map(\.0)), Actual: \(vod)")
}
lastIndex = newIndex
} else {
XCTFail("Missing parameter: \(item)")
}
}
}

func testUrlWithDrmAndPing() {
let assetID = UplynkSSAIConfiguration.ID.asset(ids: ["a123"])
let extraParams = [
("a", "1"),
("b", "2")
]
let drmAndPingConfig = UplynkSSAIConfiguration(
id: assetID,
assetType: .asset,
orderedPreplayParameters: extraParams,
contentProtected: true,
uplynkPingConfiguration: .init(adImpressions: true, freeWheelVideoViews: true, linearAdData: true)
)
XCTAssertEqual(
UplynkSSAIURLBuilder(ssaiConfiguration: drmAndPingConfig).buildPreplayVODURL(),
"https://content.uplynk.com/preplay/a123.json?v=2&manifest=m3u8&rmt=fps&ad.cping=1&ad.pingf=3&a=1&b=2"
)

let drmConfig = UplynkSSAIConfiguration(
id: assetID,
assetType: .asset,
orderedPreplayParameters: extraParams,
contentProtected: true
)
XCTAssertEqual(
UplynkSSAIURLBuilder(ssaiConfiguration: drmConfig).buildPreplayVODURL(),
"https://content.uplynk.com/preplay/a123.json?v=2&manifest=m3u8&rmt=fps&a=1&b=2"
)

let liveDrmConfig = UplynkSSAIConfiguration(
id: assetID,
assetType: .channel,
orderedPreplayParameters: extraParams,
contentProtected: true
)
XCTAssertEqual(
UplynkSSAIURLBuilder(ssaiConfiguration: liveDrmConfig).buildPreplayLiveURL(),
"https://content.uplynk.com/preplay/channel/a123.json?v=2&manifest=m3u8&rmt=fps&a=1&b=2"
)
}
}

struct TestConfig {
let assetType: UplynkSSAIConfiguration.AssetType
let preplayArray: [(String,String)]
let expectedParams: [(String,String)]

var url: String {
let assetID = UplynkSSAIConfiguration.ID.asset(ids: ["a123"])
let config = UplynkSSAIConfiguration(id: assetID, assetType: assetType, orderedPreplayParameters: preplayArray)
let builder = UplynkSSAIURLBuilder(ssaiConfiguration: config)
switch assetType {
case .asset: return builder.buildPreplayVODURL()
case .channel: return builder.buildPreplayLiveURL()
}
}

func assertUrlContainsPreplayParams() {
let url = self.url
for (key, value) in expectedParams {
let exptectation = "\(key)=\(value)"
if !url.contains(exptectation) {
XCTFail("Generated url (\(url)) does not contain \(exptectation)")
}
}
guard let parsedUrl = URLComponents(string: url) else {
return XCTFail("Could not parse the generated URL \(url)")
}
let parsedQueryItems = parsedUrl.queryItems ?? []
for (key, value) in preplayArray {
guard parsedQueryItems.contains(where: { item in
item.name == key && item.value == value
}) else {
return XCTFail("Generated URL does not contain preplay param \(key)=\(value)")
}
}
}
}
4 changes: 3 additions & 1 deletion Code/Uplynk/docs/preplay.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ We start by creating an `UplynkSSAIConfiguration` object that describes how to c

- `preplayParameters`: The `preplayParameters` object should have string-key-string-value combinations, which will be used as query parameters for the Preplay API call. Nested objects are not supported.

- `orderedPreplayParameters`: The `orderedPreplayParameters` object should have string-key-string-value combinations, which will be used as query parameters for the Preplay API call. Nested objects are not supported. Unlike `preplayParameters`, `orderedPreplayParameters` preserves the order of parameters while making a request, which is neccessary to prevent unsigned parameters that could break signature validation.

- `contentProtected`: Boolean value which will internally set any necessary content-protection information. No content-protection details have to be specified by the customer.

- **A Preplay request must include all parameters defined within the playback request, hence these parameters must be included in the THEOplayer source**. This request must also include a digital signature if the 'Require a token for playback' option is enabled in the back-end on the corresponding live channel. (See also : [Signing a Playback URL Tutorial](https://docs.uplynk.com/docs/sign-playback-url))
Expand Down Expand Up @@ -62,7 +64,7 @@ Ad specific parameters can be passed in the `preplayParameters` argument of the
assetType: ...,
prefix: ...,
preplayParameters: [
// Parameters here should specify the necessary ad parameters for the Preplay API
// Parameters here should specify the necessary ad parameters for the Preplay API. Use `orderedPreplayParameters` instead to pass these in the order given.
"ad.param1": "param_val1",
"ad.param2": "param_val2"
],
Expand Down
Loading