Skip to content
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Fixed

- SideloadedSubtitle
- Fixed an issue where variant playlists failed to load when the master playlist listed them as relative URIs with a query string, because the query string was percent-encoded into the path.
- Fixed an issue where percent-escaped characters in playlist URIs (for example `%2B`, `%2F` or `%3D` in a signed token) were decoded, which could break segment, key or variant requests.

## [11.0.7] - 2026-11-05

### Fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,23 +50,19 @@ class PlaylistParser {
}

func getFullURL(from path: String) -> URL? {
// Remove any existing percentencoding and add 1 pass of percentEncoding to make sure correct URL can be build up on all iOS version
guard let trimmedPath = path.trimmingCharacters(in: .whitespacesAndNewlines).removingPercentEncoding?.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
return nil
}
var url: URL? = nil
if #available(iOS 17.0, tvOS 17.0, *) {
url = URL(string: trimmedPath, encodingInvalidCharacters: false) // don't allow an extra percentEncoding pass.
} else {
url = URL(string: trimmedPath)
}
if let createdUrl = url,
createdUrl.scheme == nil,
let decodedPath = trimmedPath.removingPercentEncoding { // drop the percentEncoding as this will be reapplied by appendingPathComponent
return self.manifestURL.deletingLastPathComponent().appendingPathComponent(decodedPath)
let uri = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard !uri.isEmpty else { return nil }

if let url = URL(string: uri, relativeTo: self.manifestURL) {
return url.absoluteURL
}

return url

// Before iOS 17, URL(string:) rejects invalid characters such as raw spaces.
// Escape only those and keep existing %XX escapes and URL delimiters as written.
var allowed = CharacterSet.urlQueryAllowed
allowed.insert(charactersIn: "%#[]")
guard let escaped = uri.addingPercentEncoding(withAllowedCharacters: allowed) else { return nil }
return URL(string: escaped, relativeTo: self.manifestURL)?.absoluteURL
}

func updateRelativeUri(line: HLSLine) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,320 @@
import Foundation
import Swifter
import XCTest
@testable import THEOplayerConnectorSideloadedSubtitle

final class PlaylistParserTests: XCTestCase {
private let manifestURL = URL(string: "https://cdn.example.com/vod/movie/index.m3u8?token=abc")!
private let manifestURLWithoutQuery = URL(string: "https://cdn.example.com/vod/movie/index.m3u8")!

// MARK: - Relative URIs with a query string (#132)

func testRelativeURIKeepsQueryString() {
let parser = PlaylistParser(url: manifestURL, request: nil)

let url = parser.getFullURL(from: "video=391132.m3u8?hdntl=st=1~exp=2~acl=/*~hmac=ff")

XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/vod/movie/video=391132.m3u8?hdntl=st=1~exp=2~acl=/*~hmac=ff")
}

func testRelativeURIInSubdirectoryKeepsQueryString() {
let parser = PlaylistParser(url: manifestURL, request: nil)

let url = parser.getFullURL(from: "keyframes/video.m3u8?accountId=1")

XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/vod/movie/keyframes/video.m3u8?accountId=1")
}

func testRelativeURIInParentDirectoryKeepsQueryString() {
let parser = PlaylistParser(url: manifestURL, request: nil)

let url = parser.getFullURL(from: "../other/video.m3u8?accountId=1")

XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/vod/other/video.m3u8?accountId=1")
}

func testRelativeURIWithoutQueryIsResolvedAgainstManifestDirectory() {
let parser = PlaylistParser(url: manifestURL, request: nil)

let url = parser.getFullURL(from: "video.m3u8")

XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/vod/movie/video.m3u8")
}

func testAbsoluteURIIsNotChanged() {
let parser = PlaylistParser(url: manifestURL, request: nil)

let url = parser.getFullURL(from: "https://other.example.com/video.m3u8?token=xyz")

XCTAssertEqual(url?.absoluteString, "https://other.example.com/video.m3u8?token=xyz")
}

// MARK: - Encoded filenames are not double-encoded (#37: %20 must not become %2520)

func testRelativeURIWithEncodedSpacesIsNotDoubleEncoded() {
let parser = PlaylistParser(url: manifestURLWithoutQuery, request: nil)

let url = parser.getFullURL(from: "MK-CB-S01E05-es-419-227%20Soy%20una%20tazaUHD.m3u8")

XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/vod/movie/MK-CB-S01E05-es-419-227%20Soy%20una%20tazaUHD.m3u8")
}

func testRelativeURIKeepsEncodedLiteralPercent() {
let parser = PlaylistParser(url: manifestURLWithoutQuery, request: nil)

let url = parser.getFullURL(from: "100%25.m3u8")

XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/vod/movie/100%25.m3u8")
}

func testRelativeURIKeepsDoubleEncodedSpace() {
let parser = PlaylistParser(url: manifestURLWithoutQuery, request: nil)

let url = parser.getFullURL(from: "a%2520b.m3u8")

XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/vod/movie/a%2520b.m3u8")
}

// MARK: - Existing percent-escapes are kept as written (#134)

func testRelativeURIKeepsEscapedQuestionMarkInFileName() {
let parser = PlaylistParser(url: manifestURLWithoutQuery, request: nil)

let url = parser.getFullURL(from: "video%3Falt.m3u8")

XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/vod/movie/video%3Falt.m3u8")
XCTAssertNil(url?.query)
}

func testRelativeURIKeepsEscapedQueryValueBytesAndOrder() {
let parser = PlaylistParser(url: manifestURL, request: nil)

let url = parser.getFullURL(from: "video.m3u8?b=2&token=a%26b%2Bc%3Dd%25e&a=1")

XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/vod/movie/video.m3u8?b=2&token=a%26b%2Bc%3Dd%25e&a=1")
}

func testAbsoluteEncodedURIIsNotChanged() {
let parser = PlaylistParser(url: manifestURL, request: nil)

let url = parser.getFullURL(from: "https://other.example.com/path%20x/video%3Falt.m3u8?token=a%26b")

XCTAssertEqual(url?.absoluteString, "https://other.example.com/path%20x/video%3Falt.m3u8?token=a%26b")
}

func testRelativeURIKeepsEncodedURLInPathSegment() {
let parser = PlaylistParser(url: manifestURLWithoutQuery, request: nil)

let url = parser.getFullURL(from: "proxy/https%3A%2F%2Forigin.com%2Fvideo.m3u8%3Ftoken%3Dabc")

XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/vod/movie/proxy/https%3A%2F%2Forigin.com%2Fvideo.m3u8%3Ftoken%3Dabc")
}

// MARK: - Invalid characters are escaped when the URI can't be parsed (#134: raw spaces before iOS 17)

func testRelativeURIWithRawSpaceIsEscaped() {
let parser = PlaylistParser(url: manifestURLWithoutQuery, request: nil)

let url = parser.getFullURL(from: "seg one.ts")

XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/vod/movie/seg%20one.ts")
}

// MARK: - Relative references resolve to absolute URLs (#12, RFC 3986 §5.2)

func testRootRelativeURIIsResolvedAgainstManifestHost() {
let parser = PlaylistParser(url: manifestURL, request: nil)

let url = parser.getFullURL(from: "/root/video.m3u8")

XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/root/video.m3u8")
}

func testRootRelativeURIKeepsQueryString() {
let parser = PlaylistParser(url: manifestURL, request: nil)

let url = parser.getFullURL(from: "/root/video.m3u8?accountId=1")

XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/root/video.m3u8?accountId=1")
}

func testSchemeRelativeURIUsesManifestScheme() {
let parser = PlaylistParser(url: manifestURL, request: nil)

let url = parser.getFullURL(from: "//other.example.com/live/video.m3u8?token=xyz")

XCTAssertEqual(url?.absoluteString, "https://other.example.com/live/video.m3u8?token=xyz")
}

func testRelativeURIIsTrimmed() {
let parser = PlaylistParser(url: manifestURL, request: nil)

let url = parser.getFullURL(from: " \tvideo.m3u8?accountId=1 \r\n")

XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/vod/movie/video.m3u8?accountId=1")
}

func testRelativeURIInParentDirectoryDoesNotInheritManifestQuery() {
let parser = PlaylistParser(url: manifestURL, request: nil)

let url = parser.getFullURL(from: "../other/video.m3u8")

XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/vod/other/video.m3u8")
}

// MARK: - Blank playlist lines are not rewritten (VariantPlaylistParser passes every non-# line, #37)

func testEmptyURIReturnsNil() {
let parser = PlaylistParser(url: manifestURL, request: nil)

XCTAssertNil(parser.getFullURL(from: ""))
}

func testWhitespaceOnlyURIReturnsNil() {
let parser = PlaylistParser(url: manifestURL, request: nil)

XCTAssertNil(parser.getFullURL(from: " \t\r\n"))
}

// MARK: - Playlist rewriting (local HTTP fixtures)

// #12: variant and media URIs are made absolute
func testMasterPlaylistRewritesVariantAndMediaURIs() async throws {
let master = """
#EXTM3U
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud",NAME="English",URI="audio/en%20main.m3u8?token=a1"
#EXT-X-STREAM-INF:BANDWIDTH=1000,AUDIO="aud"
video=391132.m3u8?hdntl=st=1~exp=2~acl=/*~hmac=ff
#EXT-X-STREAM-INF:BANDWIDTH=2000,AUDIO="aud"
MK-CB-S01E05-es-419-227%20Soy%20una%20tazaUHD.m3u8
#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=500,URI="iframes/i.m3u8"

"""
let (server, base) = try startServer(["/vod/master.m3u8": master])
defer { server.stop() }
let parser = MasterPlaylistParser(url: URL(string: "\(base)/vod/master.m3u8?token=parent")!, request: nil)

let data = await parser.sideLoadSubtitles(subtitles: [])

let lines = String(decoding: try XCTUnwrap(data), as: UTF8.self).components(separatedBy: "\n")
XCTAssertTrue(lines.contains("\(base)/vod/video=391132.m3u8?hdntl=st=1~exp=2~acl=/*~hmac=ff"), "\(lines)")
XCTAssertTrue(lines.contains("\(base)/vod/MK-CB-S01E05-es-419-227%20Soy%20una%20tazaUHD.m3u8"), "\(lines)")
XCTAssertTrue(lines.contains { $0.hasPrefix("#EXT-X-MEDIA:") && $0.contains("URI=\"\(base)/vod/audio/en%20main.m3u8?token=a1\"") }, "\(lines)")
XCTAssertTrue(lines.contains { $0.hasPrefix("#EXT-X-I-FRAME-STREAM-INF:") && $0.contains("URI=\"\(base)/vod/iframes/i.m3u8\"") }, "\(lines)")
}

// #17: the final URL after a redirect is the base
func testMasterPlaylistResolvesAgainstRedirectedURL() async throws {
let master = "#EXTM3U\n#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"English\",URI=\"audio.m3u8\"\n#EXT-X-STREAM-INF:BANDWIDTH=1000,AUDIO=\"aud\"\nvideo.m3u8?sig=1\n"
let (server, base) = try startServer(["/final/dir/master.m3u8": master])
defer { server.stop() }
server["/start/master.m3u8"] = { _ in .movedTemporarily("/final/dir/master.m3u8") }
let parser = MasterPlaylistParser(url: URL(string: "\(base)/start/master.m3u8")!, request: nil)

let data = await parser.sideLoadSubtitles(subtitles: [])

XCTAssertEqual(parser.manifestURL.absoluteString, "\(base)/final/dir/master.m3u8")
let lines = String(decoding: try XCTUnwrap(data), as: UTF8.self).components(separatedBy: "\n")
XCTAssertTrue(lines.contains("\(base)/final/dir/video.m3u8?sig=1"), "\(lines)")
XCTAssertTrue(lines.contains { $0.contains("URI=\"\(base)/final/dir/audio.m3u8\"") }, "\(lines)")
}

// #20: EXT-X-KEY/EXT-X-MAP URI attributes; #37: EXT-X-BYTERANGE between EXTINF and the segment
func testVariantPlaylistRewritesSegmentKeyAndMapURIs() async throws {
let variant = """
#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:6
#EXT-X-MAP:URI="init%20file.mp4?sig=a1"
#EXT-X-KEY:METHOD=AES-128,URI="keys/key.bin?token=k1&exp=2"
#EXTINF:6.000,
#EXT-X-BYTERANGE:1000@0
seg%20one.ts?hdntl=exp=2~acl=/*~hmac=ff
#EXTINF:4.500,
#EXT-X-PROGRAM-DATE-TIME:2024-01-01T00:00:06.000Z
../shared/seg2.ts
#EXT-X-ENDLIST

"""
let (server, base) = try startServer(["/vod/movie/video.m3u8": variant])
defer { server.stop() }
let parser = VariantPlaylistParser(url: URL(string: "\(base)/vod/movie/video.m3u8?token=parent")!, request: nil)

let result = await parser.parse()

let parsed = try XCTUnwrap(result)
XCTAssertEqual(parsed.totalPlayListDuration, 10.5, accuracy: 0.0001)
let lines = String(decoding: try XCTUnwrap(parsed.manifestData), as: UTF8.self).components(separatedBy: "\n")
// Only the duration is compared for EXTINF: the parser drops the empty title separator.
let comparableLines = lines.map { $0.hasPrefix("#EXTINF:") ? String($0.prefix { $0 != "," }) : $0 }
XCTAssertEqual(comparableLines, [
"#EXTM3U",
"#EXT-X-VERSION:7",
"#EXT-X-TARGETDURATION:6",
"#EXT-X-MAP:URI=\"\(base)/vod/movie/init%20file.mp4?sig=a1\"",
lines.first { $0.hasPrefix("#EXT-X-KEY:") } ?? "<missing EXT-X-KEY>",
"#EXTINF:6.000",
"#EXT-X-BYTERANGE:1000@0",
"\(base)/vod/movie/seg%20one.ts?hdntl=exp=2~acl=/*~hmac=ff",
"#EXTINF:4.500",
"#EXT-X-PROGRAM-DATE-TIME:2024-01-01T00:00:06.000Z",
"\(base)/vod/shared/seg2.ts",
"#EXT-X-ENDLIST",
])
let keyLine = try XCTUnwrap(lines.first { $0.hasPrefix("#EXT-X-KEY:") })
XCTAssertTrue(keyLine.contains("METHOD=AES-128"), keyLine)
XCTAssertTrue(keyLine.contains("URI=\"\(base)/vod/movie/keys/key.bin?token=k1&exp=2\""), keyLine)
}

// #134
func testVariantPlaylistKeepsEscapedSignatureBytes() async throws {
let variant = """
#EXTM3U
#EXT-X-TARGETDURATION:6
#EXT-X-KEY:METHOD=AES-128,URI="key.bin?sig=k%2F1%2B%3D"
#EXTINF:6.000,
seg1.ts?exp=2&sig=ab%2Fcd%2B%3D%3D
#EXT-X-ENDLIST

"""
let (server, base) = try startServer(["/vod/movie/video.m3u8": variant])
defer { server.stop() }
let parser = VariantPlaylistParser(url: URL(string: "\(base)/vod/movie/video.m3u8")!, request: nil)

let result = await parser.parse()

let lines = String(decoding: try XCTUnwrap(try XCTUnwrap(result).manifestData), as: UTF8.self).components(separatedBy: "\n")
XCTAssertTrue(lines.contains("\(base)/vod/movie/seg1.ts?exp=2&sig=ab%2Fcd%2B%3D%3D"), "\(lines)")
let keyLine = try XCTUnwrap(lines.first { $0.hasPrefix("#EXT-X-KEY:") })
XCTAssertTrue(keyLine.contains("URI=\"\(base)/vod/movie/key.bin?sig=k%2F1%2B%3D\""), keyLine)
}

// #134: raw-space segment is kept before iOS 17
func testVariantPlaylistKeepsSegmentWithRawSpace() async throws {
let variant = """
#EXTM3U
#EXT-X-TARGETDURATION:6
#EXTINF:6.000,
seg one.ts
#EXT-X-ENDLIST

"""
let (server, base) = try startServer(["/video.m3u8": variant])
defer { server.stop() }
let parser = VariantPlaylistParser(url: URL(string: "\(base)/video.m3u8")!, request: nil)

let result = await parser.parse()

let lines = String(decoding: try XCTUnwrap(try XCTUnwrap(result).manifestData), as: UTF8.self).components(separatedBy: "\n")
XCTAssertTrue(lines.contains("\(base)/seg%20one.ts"), "\(lines)")
}

private func startServer(_ playlists: [String: String]) throws -> (HttpServer, String) {
let server = HttpServer()
for (path, playlist) in playlists {
server[path] = { _ in .ok(.data(Data(playlist.utf8), contentType: "application/vnd.apple.mpegurl")) }
}
try server.start(0, forceIPv4: true, priority: .userInitiated)
return (server, "http://127.0.0.1:\(try server.port())")
}
}
Loading