From a597ceb1beb5ffbb02fa3318ede18225bcc71dec Mon Sep 17 00:00:00 2001 From: Jakub Olejnik Date: Wed, 4 Nov 2026 17:36:56 +0100 Subject: [PATCH 1/7] Keep query string when resolving relative playlist URIs getFullURL built absolute URLs for relative URIs with appendingPathComponent. That method percent-encodes '?' into '%3F', so a variant URI such as 'video.m3u8?hdntl=...' became part of the path and the server returned HTTP 400. Resolve the URI against the manifest URL with URL(string:relativeTo:) instead. Fall back to the previous behavior when that fails. --- .../Parsers/PlaylistParser.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/Parsers/PlaylistParser.swift b/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/Parsers/PlaylistParser.swift index aad3cf55..f635a0ba 100644 --- a/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/Parsers/PlaylistParser.swift +++ b/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/Parsers/PlaylistParser.swift @@ -61,9 +61,15 @@ class PlaylistParser { 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) + createdUrl.scheme == nil { + // Resolve against the manifest URL so a query string (e.g. `?hdntl=...`) stays a query + // instead of being percent-encoded into the path by `appendingPathComponent`. + if let resolvedUrl = URL(string: trimmedPath, relativeTo: self.manifestURL)?.absoluteURL { + return resolvedUrl + } + if let decodedPath = trimmedPath.removingPercentEncoding { // drop the percentEncoding as this will be reapplied by appendingPathComponent + return self.manifestURL.deletingLastPathComponent().appendingPathComponent(decodedPath) + } } return url From e568e2c0c507b24a71ccc0a7563bca11c5dee945 Mon Sep 17 00:00:00 2001 From: Jakub Olejnik Date: Thu, 5 Nov 2026 00:57:28 +0100 Subject: [PATCH 2/7] Add tests for resolving relative playlist URIs Cover relative URIs with a query string in the same directory, in a subdirectory and in a parent directory, a relative URI without a query, and an absolute URI. A relative URI without its own query does not get the playlist's query, as RFC 3986 section 5.2.2 specifies. --- .../PlaylistParserTests.swift | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/PlaylistParserTests.swift diff --git a/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/PlaylistParserTests.swift b/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/PlaylistParserTests.swift new file mode 100644 index 00000000..afc06e91 --- /dev/null +++ b/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/PlaylistParserTests.swift @@ -0,0 +1,47 @@ +import Foundation +import XCTest +@testable import THEOplayerConnectorSideloadedSubtitle + +final class PlaylistParserTests: XCTestCase { + private let manifestURL = URL(string: "https://cdn.example.com/vod/movie/index.m3u8?token=abc")! + + 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") + } +} From 7b797c75ead7e21ed8d6f9261c2b3e6d3c37acc9 Mon Sep 17 00:00:00 2001 From: Jakub Olejnik Date: Thu, 5 Nov 2026 00:58:51 +0100 Subject: [PATCH 3/7] Add changelog entry for relative playlist URIs with a query string Co-Authored-By: raffi.manissalian --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bab123c2..ff1f8581 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ 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. + ## [11.0.7] - 2026-11-05 ### Fixed From f4dcfa0974b5b456fd4edacda81be2d15e2a805e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 07:32:22 -0800 Subject: [PATCH 4/7] Resolve playlist URIs without decoding existing percent-escapes Use URL(string:relativeTo:) on the trimmed URI as written instead of removing and re-adding percent-encoding, so escaped characters such as %3F, %26, %2B, %3D and %2F stay as data. Blank lines still return nil. Add regression tests for encoded filenames, escaped query values, redirects and playlist URI rewriting. Co-Authored-By: raffi.manissalian --- .../Parsers/PlaylistParser.swift | 26 +- .../PlaylistParserTests.swift | 233 ++++++++++++++++++ 2 files changed, 236 insertions(+), 23 deletions(-) diff --git a/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/Parsers/PlaylistParser.swift b/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/Parsers/PlaylistParser.swift index f635a0ba..798babd2 100644 --- a/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/Parsers/PlaylistParser.swift +++ b/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/Parsers/PlaylistParser.swift @@ -50,29 +50,9 @@ 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 { - // Resolve against the manifest URL so a query string (e.g. `?hdntl=...`) stays a query - // instead of being percent-encoded into the path by `appendingPathComponent`. - if let resolvedUrl = URL(string: trimmedPath, relativeTo: self.manifestURL)?.absoluteURL { - return resolvedUrl - } - if let decodedPath = trimmedPath.removingPercentEncoding { // drop the percentEncoding as this will be reapplied by appendingPathComponent - return self.manifestURL.deletingLastPathComponent().appendingPathComponent(decodedPath) - } - } - - return url + let uri = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !uri.isEmpty else { return nil } + return URL(string: uri, relativeTo: self.manifestURL)?.absoluteURL } func updateRelativeUri(line: HLSLine) { diff --git a/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/PlaylistParserTests.swift b/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/PlaylistParserTests.swift index afc06e91..e7891a31 100644 --- a/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/PlaylistParserTests.swift +++ b/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/PlaylistParserTests.swift @@ -1,9 +1,11 @@ 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")! func testRelativeURIKeepsQueryString() { let parser = PlaylistParser(url: manifestURL, request: nil) @@ -44,4 +46,235 @@ final class PlaylistParserTests: XCTestCase { XCTAssertEqual(url?.absoluteString, "https://other.example.com/video.m3u8?token=xyz") } + + // MARK: - Percent-encoding (PR37 regression: %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") + } + + 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: - Reference forms + + 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 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")) + } + + 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: - Playlist rewriting (local HTTP fixtures) + + 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)") + } + + 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)") + } + + 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:") } ?? "", + "#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) + } + + 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) + } + + 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())") + } } From a51dec6f9cfaf1e31090292e5121eff6fe87626d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 07:50:44 -0800 Subject: [PATCH 5/7] Add changelog entry for preserving percent-escapes in playlist URIs Co-Authored-By: raffi.manissalian --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff1f8581..b73404c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 From 325038ab51978a20646273ab3917e4d1ecb8dd1e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:25:51 -0800 Subject: [PATCH 6/7] SideloadedSubtitle: Escape invalid characters when a playlist URI can't be parsed Co-Authored-By: raffi.manissalian --- .../Parsers/PlaylistParser.swift | 12 ++++++++- .../PlaylistParserTests.swift | 27 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/Parsers/PlaylistParser.swift b/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/Parsers/PlaylistParser.swift index 798babd2..6811714e 100644 --- a/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/Parsers/PlaylistParser.swift +++ b/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/Parsers/PlaylistParser.swift @@ -52,7 +52,17 @@ class PlaylistParser { func getFullURL(from path: String) -> URL? { let uri = path.trimmingCharacters(in: .whitespacesAndNewlines) guard !uri.isEmpty else { return nil } - return URL(string: uri, relativeTo: self.manifestURL)?.absoluteURL + + if let url = URL(string: uri, relativeTo: self.manifestURL) { + return url.absoluteURL + } + + // 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) { diff --git a/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/PlaylistParserTests.swift b/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/PlaylistParserTests.swift index e7891a31..72bac150 100644 --- a/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/PlaylistParserTests.swift +++ b/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/PlaylistParserTests.swift @@ -39,6 +39,14 @@ final class PlaylistParserTests: XCTestCase { XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/vod/movie/video.m3u8") } + 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") + } + func testAbsoluteURIIsNotChanged() { let parser = PlaylistParser(url: manifestURL, request: nil) @@ -269,6 +277,25 @@ final class PlaylistParserTests: XCTestCase { XCTAssertTrue(keyLine.contains("URI=\"\(base)/vod/movie/key.bin?sig=k%2F1%2B%3D\""), keyLine) } + 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 { From 1f69de42647b99eccdc974ff2854ac7be0cccca6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:33:31 -0800 Subject: [PATCH 7/7] SideloadedSubtitle: Group playlist parser tests by the change they cover Co-Authored-By: raffi.manissalian --- .../PlaylistParserTests.swift | 49 ++++++++++++------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/PlaylistParserTests.swift b/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/PlaylistParserTests.swift index 72bac150..8c5be99f 100644 --- a/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/PlaylistParserTests.swift +++ b/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/PlaylistParserTests.swift @@ -7,6 +7,8 @@ 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) @@ -39,14 +41,6 @@ final class PlaylistParserTests: XCTestCase { XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/vod/movie/video.m3u8") } - 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") - } - func testAbsoluteURIIsNotChanged() { let parser = PlaylistParser(url: manifestURL, request: nil) @@ -55,7 +49,7 @@ final class PlaylistParserTests: XCTestCase { XCTAssertEqual(url?.absoluteString, "https://other.example.com/video.m3u8?token=xyz") } - // MARK: - Percent-encoding (PR37 regression: %20 must not become %2520) + // MARK: - Encoded filenames are not double-encoded (#37: %20 must not become %2520) func testRelativeURIWithEncodedSpacesIsNotDoubleEncoded() { let parser = PlaylistParser(url: manifestURLWithoutQuery, request: nil) @@ -81,6 +75,8 @@ final class PlaylistParserTests: XCTestCase { 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) @@ -114,7 +110,17 @@ final class PlaylistParserTests: XCTestCase { XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/vod/movie/proxy/https%3A%2F%2Forigin.com%2Fvideo.m3u8%3Ftoken%3Dabc") } - // MARK: - Reference forms + // 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) @@ -148,28 +154,31 @@ final class PlaylistParserTests: XCTestCase { XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/vod/movie/video.m3u8?accountId=1") } - func testEmptyURIReturnsNil() { + func testRelativeURIInParentDirectoryDoesNotInheritManifestQuery() { let parser = PlaylistParser(url: manifestURL, request: nil) - XCTAssertNil(parser.getFullURL(from: "")) + let url = parser.getFullURL(from: "../other/video.m3u8") + + XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/vod/other/video.m3u8") } - func testWhitespaceOnlyURIReturnsNil() { + // 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: " \t\r\n")) + XCTAssertNil(parser.getFullURL(from: "")) } - func testRelativeURIInParentDirectoryDoesNotInheritManifestQuery() { + func testWhitespaceOnlyURIReturnsNil() { 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") + 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 @@ -194,6 +203,7 @@ final class PlaylistParserTests: XCTestCase { 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]) @@ -209,6 +219,7 @@ final class PlaylistParserTests: XCTestCase { 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 @@ -255,6 +266,7 @@ final class PlaylistParserTests: XCTestCase { XCTAssertTrue(keyLine.contains("URI=\"\(base)/vod/movie/keys/key.bin?token=k1&exp=2\""), keyLine) } + // #134 func testVariantPlaylistKeepsEscapedSignatureBytes() async throws { let variant = """ #EXTM3U @@ -277,6 +289,7 @@ final class PlaylistParserTests: XCTestCase { 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