├── .github └── workflows │ └── ci.yml ├── .gitignore ├── HLSCachingReverseProxyServer.podspec ├── LICENSE ├── Makefile ├── Package.resolved ├── Package.swift ├── Podfile ├── Podfile.lock ├── README.md ├── Sources └── HLSCachingReverseProxyServer │ └── HLSCachingReverseProxyServer.swift ├── Tests └── HLSCachingReverseProxyServerTests │ └── HLSCachingReverseProxyServerTests.swift └── codecov.yml /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | runs-on: macOS-latest 12 | env: 13 | WORKSPACE: HLSCachingReverseProxyServer.xcworkspace 14 | SCHEME: HLSCachingReverseProxyServer-Package 15 | SDK: iphonesimulator 16 | DESTINATION: platform=iOS Simulator,name=iPhone 11 Pro,OS=latest 17 | 18 | steps: 19 | - uses: actions/checkout@v1 20 | 21 | - name: Generate Xcode Project 22 | run: make project 23 | 24 | - name: Build and Test 25 | run: | 26 | set -o pipefail && xcodebuild clean build test \ 27 | -workspace "$WORKSPACE" \ 28 | -scheme "$SCHEME" \ 29 | -sdk "$SDK" \ 30 | -destination "$DESTINATION" \ 31 | -configuration Debug \ 32 | -enableCodeCoverage YES \ 33 | CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO | xcpretty -c 34 | 35 | - name: Upload Code Coverage 36 | run: | 37 | bash <(curl -s https://codecov.io/bash) \ 38 | -X xcodeplist \ 39 | -J HLSCachingReverseProxyServer \ 40 | -t "$CODECOV_TOKEN" 41 | env: 42 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | /*.xcworkspace 6 | **/xcuserdata 7 | **/xcshareddata 8 | Pods/ 9 | Carthage/ 10 | -------------------------------------------------------------------------------- /HLSCachingReverseProxyServer.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = "HLSCachingReverseProxyServer" 3 | s.version = "0.2.0" 4 | s.summary = "A simple local reverse proxy server for HLS segment cache" 5 | s.homepage = "https://github.com/StyleShare/HLSCachingReverseProxyServer" 6 | s.license = { :type => "MIT", :file => "LICENSE" } 7 | s.author = { "StyleShare" => "dev-ios@styleshare.kr" } 8 | s.source = { :git => "https://github.com/StyleShare/HLSCachingReverseProxyServer.git", 9 | :tag => s.version.to_s } 10 | s.source_files = "Sources/**/*.{swift,h,m}" 11 | s.frameworks = "Foundation" 12 | s.swift_version = "5.1" 13 | 14 | s.dependency "GCDWebServer", "~> 3.5" 15 | s.dependency "PINCache", "~> 3.0" 16 | 17 | s.ios.deployment_target = "8.0" 18 | s.osx.deployment_target = "10.11" 19 | s.tvos.deployment_target = "9.0" 20 | end 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Suyeol Jeon (xoul.kr) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | project: clean 2 | swift package generate-xcodeproj --enable-code-coverage 3 | ruby -e "require 'xcodeproj'; Xcodeproj::Project.open('HLSCachingReverseProxyServer.xcodeproj').save" || true 4 | pod install 5 | 6 | clean: 7 | rm -rf Pods 8 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "CwlCatchException", 6 | "repositoryURL": "https://github.com/mattgallagher/CwlCatchException.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "f809deb30dc5c9d9b78c872e553261a61177721a", 10 | "version": "2.0.0" 11 | } 12 | }, 13 | { 14 | "package": "CwlPreconditionTesting", 15 | "repositoryURL": "https://github.com/mattgallagher/CwlPreconditionTesting.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "02b7a39a99c4da27abe03cab2053a9034379639f", 19 | "version": "2.0.0" 20 | } 21 | }, 22 | { 23 | "package": "Nimble", 24 | "repositoryURL": "https://github.com/Quick/Nimble.git", 25 | "state": { 26 | "branch": null, 27 | "revision": "e491a6731307bb23783bf664d003be9b2fa59ab5", 28 | "version": "9.0.0" 29 | } 30 | }, 31 | { 32 | "package": "SafeCollection", 33 | "repositoryURL": "https://github.com/devxoul/SafeCollection.git", 34 | "state": { 35 | "branch": null, 36 | "revision": "8ddc40807b203b731a30c3dfd4deb978967d948b", 37 | "version": "3.1.0" 38 | } 39 | } 40 | ] 41 | }, 42 | "version": 1 43 | } 44 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "HLSCachingReverseProxyServer", 7 | platforms: [ 8 | .macOS(.v10_11), .iOS(.v9), .tvOS(.v9) 9 | ], 10 | products: [ 11 | .library(name: "HLSCachingReverseProxyServer", targets: ["HLSCachingReverseProxyServer"]), 12 | ], 13 | dependencies: [ 14 | .package(url: "https://github.com/Quick/Nimble.git", .upToNextMajor(from: "9.0.0")), 15 | .package(url: "https://github.com/devxoul/SafeCollection.git", .upToNextMajor(from: "3.1.0")), 16 | ], 17 | targets: [ 18 | .target(name: "HLSCachingReverseProxyServer"), 19 | .testTarget(name: "HLSCachingReverseProxyServerTests", dependencies: ["HLSCachingReverseProxyServer", "Nimble", "SafeCollection"]), 20 | ] 21 | ) 22 | -------------------------------------------------------------------------------- /Podfile: -------------------------------------------------------------------------------- 1 | use_frameworks! 2 | inhibit_all_warnings! 3 | 4 | target 'HLSCachingReverseProxyServer' do 5 | platform :ios, '9.0' 6 | 7 | pod 'GCDWebServer', '~> 3.5' 8 | pod 'PINCache', '~> 3.0' 9 | 10 | target 'HLSCachingReverseProxyServerTests' do 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - GCDWebServer (3.5.3): 3 | - GCDWebServer/Core (= 3.5.3) 4 | - GCDWebServer/Core (3.5.3) 5 | - PINCache (3.0.3): 6 | - PINCache/Arc-exception-safe (= 3.0.3) 7 | - PINCache/Core (= 3.0.3) 8 | - PINCache/Arc-exception-safe (3.0.3): 9 | - PINCache/Core 10 | - PINCache/Core (3.0.3): 11 | - PINOperation (~> 1.2.1) 12 | - PINOperation (1.2.1) 13 | 14 | DEPENDENCIES: 15 | - GCDWebServer (~> 3.5) 16 | - PINCache (~> 3.0) 17 | 18 | SPEC REPOS: 19 | trunk: 20 | - GCDWebServer 21 | - PINCache 22 | - PINOperation 23 | 24 | SPEC CHECKSUMS: 25 | GCDWebServer: c0ab22c73e1b84f358d1e2f74bf6afd1c60829f2 26 | PINCache: 7a8fc1a691173d21dbddbf86cd515de6efa55086 27 | PINOperation: 00c935935f1e8cf0d1e2d6b542e75b88fc3e5e20 28 | 29 | PODFILE CHECKSUM: 396a8d24399c0a48e03b0b0819f1e2337c395211 30 | 31 | COCOAPODS: 1.10.0 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HLSCachingReverseProxyServer 2 | 3 | ![Swift](https://img.shields.io/badge/Swift-5.1-orange.svg) 4 | [![CocoaPods](http://img.shields.io/cocoapods/v/HLSCachingReverseProxyServer.svg)](https://cocoapods.org/pods/HLSCachingReverseProxyServer) 5 | [![Build Status](https://github.com/StyleShare/HLSCachingReverseProxyServer/workflows/CI/badge.svg)](https://github.com/StyleShare/HLSCachingReverseProxyServer/actions) 6 | [![CodeCov](https://img.shields.io/codecov/c/github/StyleShare/HLSCachingReverseProxyServer.svg)](https://codecov.io/gh/StyleShare/HLSCachingReverseProxyServer) 7 | 8 | A simple local reverse proxy server for HLS segment cache. 9 | 10 | ## How it works 11 | 12 | ![HLSCachingReverseProxyServer Concept](https://user-images.githubusercontent.com/931655/69081879-45206a80-0a82-11ea-8fca-3c09f3b1ebb1.png) 13 | 14 | 1. **User** sets a reverse proxy url to the `AVPlayer` instead of the origin url. 15 | ```diff 16 | - https://example.com/vod.m3u8 17 | + http://127.0.0.1:8080/vod.m3u8?__hls_origin_url=https://example.com/vod.m3u8 18 | ``` 19 | 2. **AVPlayer** requests a playlist(`.m3u8`) to the local reverse proxy server. 20 | 3. **Reverse proxy server** fetches the origin playlist and replaces all URIs to point the localhost. 21 | ```diff 22 | #EXTM3U 23 | #EXTINF:12.000, 24 | - vod_00001.ts 25 | + http://127.0.0.1:8080/vod.m3u8?__hls_origin_url=https://example.com/vod_00001.ts 26 | #EXTINF:12.000, 27 | - vod_00002.ts 28 | + http://127.0.0.1:8080/vod.m3u8?__hls_origin_url=https://example.com/vod_00002.ts 29 | #EXTINF:12.000, 30 | - vod_00003.ts 31 | + http://127.0.0.1:8080/vod.m3u8?__hls_origin_url=https://example.com/vod_00003.ts 32 | ``` 33 | 4. **AVPlayer** requests segments(`.ts`) to the local reverse proxy server. 34 | 5. **Reverse proxy server** fetches the origin segment and caches it. Next time the server will return the cached data for the same segment. 35 | 36 | ## Usage 37 | 38 | ```swift 39 | let server = HLSCachingReverseProxyServer() 40 | server.start(port: 8080) 41 | 42 | let playlistURL = URL(string: "http://devstreaming.apple.com/videos/wwdc/2016/102w0bsn0ge83qfv7za/102/0640/0640.m3u8")! 43 | let reverseProxyURL = server.reverseProxyURL(from: playlistURL)! 44 | let playerItem = AVPlayerItem(url: reverseProxyURL) 45 | self.player.replaceCurrentItem(with: playerItem) 46 | ``` 47 | 48 | ## Dependencies 49 | 50 | * [GCDWebServer](https://github.com/swisspol/GCDWebServer) 51 | * [PINCache](https://github.com/pinterest/PINCache) 52 | 53 | ## Installation 54 | 55 | Use CocoaPods with **Podfile**: 56 | 57 | ```ruby 58 | pod 'HLSCachingReverseProxyServer' 59 | ``` 60 | 61 | ## Development 62 | 63 | ```console 64 | $ make project 65 | $ open HLSCachingReverseProxyServer.xcworkspace 66 | ``` 67 | 68 | ## License 69 | 70 | HLSCachingReverseProxyServer is under MIT license. See the [LICENSE](LICENSE) for more info. 71 | -------------------------------------------------------------------------------- /Sources/HLSCachingReverseProxyServer/HLSCachingReverseProxyServer.swift: -------------------------------------------------------------------------------- 1 | import GCDWebServer 2 | import PINCache 3 | 4 | open class HLSCachingReverseProxyServer { 5 | static let originURLKey = "__hls_origin_url" 6 | 7 | private let webServer: GCDWebServer 8 | private let urlSession: URLSession 9 | private let cache: PINCaching 10 | 11 | private(set) var port: Int? 12 | 13 | public init(webServer: GCDWebServer, urlSession: URLSession, cache: PINCaching) { 14 | self.webServer = webServer 15 | self.urlSession = urlSession 16 | self.cache = cache 17 | 18 | self.addRequestHandlers() 19 | } 20 | 21 | 22 | // MARK: Starting and Stopping Server 23 | 24 | open func start(port: UInt) { 25 | guard !self.webServer.isRunning else { return } 26 | self.port = Int(port) 27 | self.webServer.start(withPort: port, bonjourName: nil) 28 | } 29 | 30 | open func stop() { 31 | guard self.webServer.isRunning else { return } 32 | self.port = nil 33 | self.webServer.stop() 34 | } 35 | 36 | 37 | // MARK: Resource URL 38 | 39 | open func reverseProxyURL(from originURL: URL) -> URL? { 40 | guard let port = self.port else { return nil } 41 | 42 | guard var components = URLComponents(url: originURL, resolvingAgainstBaseURL: false) else { return nil } 43 | components.scheme = "http" 44 | components.host = "127.0.0.1" 45 | components.port = port 46 | 47 | let originURLQueryItem = URLQueryItem(name: Self.originURLKey, value: originURL.absoluteString) 48 | components.queryItems = (components.queryItems ?? []) + [originURLQueryItem] 49 | 50 | return components.url 51 | } 52 | 53 | 54 | // MARK: Request Handler 55 | 56 | private func addRequestHandlers() { 57 | self.addPlaylistHandler() 58 | self.addSegmentHandler() 59 | } 60 | 61 | private func addPlaylistHandler() { 62 | self.webServer.addHandler(forMethod: "GET", pathRegex: "^/.*\\.m3u8$", request: GCDWebServerRequest.self) { [weak self] request, completion in 63 | guard let self = self else { 64 | return completion(GCDWebServerDataResponse(statusCode: 500)) 65 | } 66 | 67 | guard let originURL = self.originURL(from: request) else { 68 | return completion(GCDWebServerErrorResponse(statusCode: 400)) 69 | } 70 | 71 | let task = self.urlSession.dataTask(with: originURL) { data, response, error in 72 | guard let data = data, let response = response else { 73 | return completion(GCDWebServerErrorResponse(statusCode: 500)) 74 | } 75 | 76 | let playlistData = self.reverseProxyPlaylist(with: data, forOriginURL: originURL) 77 | let contentType = response.mimeType ?? "application/x-mpegurl" 78 | completion(GCDWebServerDataResponse(data: playlistData, contentType: contentType)) 79 | } 80 | 81 | task.resume() 82 | } 83 | } 84 | 85 | private func addSegmentHandler() { 86 | self.webServer.addHandler(forMethod: "GET", pathRegex: "^/.*\\.ts$", request: GCDWebServerRequest.self) { [weak self] request, completion in 87 | guard let self = self else { 88 | return completion(GCDWebServerDataResponse(statusCode: 500)) 89 | } 90 | 91 | guard let originURL = self.originURL(from: request) else { 92 | return completion(GCDWebServerErrorResponse(statusCode: 400)) 93 | } 94 | 95 | if let cachedData = self.cachedData(for: originURL) { 96 | return completion(GCDWebServerDataResponse(data: cachedData, contentType: "video/mp2t")) 97 | } 98 | 99 | let task = self.urlSession.dataTask(with: originURL) { data, response, error in 100 | guard let data = data, let response = response else { 101 | return completion(GCDWebServerErrorResponse(statusCode: 500)) 102 | } 103 | 104 | let contentType = response.mimeType ?? "video/mp2t" 105 | completion(GCDWebServerDataResponse(data: data, contentType: contentType)) 106 | 107 | self.saveCacheData(data, for: originURL) 108 | } 109 | 110 | task.resume() 111 | } 112 | } 113 | 114 | private func originURL(from request: GCDWebServerRequest) -> URL? { 115 | guard let encodedURLString = request.query?[Self.originURLKey] else { return nil } 116 | guard let urlString = encodedURLString.removingPercentEncoding else { return nil } 117 | let url = URL(string: urlString) 118 | return url 119 | } 120 | 121 | 122 | // MARK: Manipulating Playlist 123 | 124 | private func reverseProxyPlaylist(with data: Data, forOriginURL originURL: URL) -> Data { 125 | return String(data: data, encoding: .utf8)! 126 | .components(separatedBy: .newlines) 127 | .map { line in self.processPlaylistLine(line, forOriginURL: originURL) } 128 | .joined(separator: "\n") 129 | .data(using: .utf8)! 130 | } 131 | 132 | private func processPlaylistLine(_ line: String, forOriginURL originURL: URL) -> String { 133 | guard !line.isEmpty else { return line } 134 | 135 | if line.hasPrefix("#") { 136 | return self.lineByReplacingURI(line: line, forOriginURL: originURL) 137 | } 138 | 139 | if let originalSegmentURL = self.absoluteURL(from: line, forOriginURL: originURL), 140 | let reverseProxyURL = self.reverseProxyURL(from: originalSegmentURL) { 141 | return reverseProxyURL.absoluteString 142 | } 143 | 144 | return line 145 | } 146 | 147 | private func lineByReplacingURI(line: String, forOriginURL originURL: URL) -> String { 148 | let uriPattern = try! NSRegularExpression(pattern: "URI=\"(.*)\"") 149 | let lineRange = NSMakeRange(0, line.count) 150 | guard let result = uriPattern.firstMatch(in: line, options: [], range: lineRange) else { return line } 151 | 152 | let uri = (line as NSString).substring(with: result.range(at: 1)) 153 | guard let absoluteURL = self.absoluteURL(from: uri, forOriginURL: originURL) else { return line } 154 | guard let reverseProxyURL = self.reverseProxyURL(from: absoluteURL) else { return line } 155 | 156 | return uriPattern.stringByReplacingMatches(in: line, options: [], range: lineRange, withTemplate: "URI=\"\(reverseProxyURL.absoluteString)\"") 157 | } 158 | 159 | private func absoluteURL(from line: String, forOriginURL originURL: URL) -> URL? { 160 | guard ["m3u8", "ts"].contains(originURL.pathExtension) else { return nil } 161 | 162 | if line.hasPrefix("http://") || line.hasPrefix("https://") { 163 | return URL(string: line) 164 | } 165 | 166 | guard let scheme = originURL.scheme, let host = originURL.host else { return nil } 167 | 168 | let path: String 169 | if line.hasPrefix("/") { 170 | path = line 171 | } else { 172 | path = originURL.deletingLastPathComponent().appendingPathComponent(line).path 173 | } 174 | 175 | return URL(string: scheme + "://" + host + path)?.standardized 176 | } 177 | 178 | 179 | // MARK: Caching 180 | 181 | private func cachedData(for resourceURL: URL) -> Data? { 182 | let key = self.cacheKey(for: resourceURL) 183 | return self.cache.object(forKey: key) as? Data 184 | } 185 | 186 | private func saveCacheData(_ data: Data, for resourceURL: URL) { 187 | let key = self.cacheKey(for: resourceURL) 188 | self.cache.setObject(data, forKey: key) 189 | } 190 | 191 | private func cacheKey(for resourceURL: URL) -> String { 192 | return resourceURL.absoluteString.data(using: .utf8)!.base64EncodedString() 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /Tests/HLSCachingReverseProxyServerTests/HLSCachingReverseProxyServerTests.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | import XCTest 3 | 4 | import GCDWebServer 5 | import Nimble 6 | import PINCache 7 | import SafeCollection 8 | 9 | import HLSCachingReverseProxyServer 10 | 11 | final class HLSCachingReverseProxyServerTests: XCTestCase { 12 | private var webServer: GCDWebServer! 13 | private var urlSession: URLSession! 14 | private var cache: PINCache! 15 | private var server: HLSCachingReverseProxyServer! 16 | 17 | override func setUp() { 18 | super.setUp() 19 | URLProtocolSpy.register() 20 | 21 | self.webServer = GCDWebServer() 22 | self.cache = PINCache.shared 23 | self.cache.removeAllObjects() 24 | self.urlSession = URLSession.shared //(configuration: .default) 25 | self.server = HLSCachingReverseProxyServer(webServer: self.webServer, urlSession: self.urlSession, cache: self.cache) 26 | self.server.start(port: 1234) 27 | } 28 | 29 | override func tearDown() { 30 | self.server.stop() 31 | URLProtocolSpy.unregister() 32 | self.cache.removeAllObjects() 33 | super.tearDown() 34 | } 35 | 36 | func testReverseProxyURL_returnsLocalhostURLWithOriginURL() { 37 | let originURL = URL(string: "https://example.com/hls/playlists/vod.m3u8")! 38 | let reverseProxyURL = self.server.reverseProxyURL(from: originURL)! 39 | expect(reverseProxyURL.absoluteString) == "http://127.0.0.1:1234/hls/playlists/vod.m3u8?__hls_origin_url=https://example.com/hls/playlists/vod.m3u8" 40 | } 41 | 42 | func testReverseProxyURL_returnsNilWhenServerNotRunning() { 43 | self.server.stop() 44 | 45 | let originURL = URL(string: "https://example.com/hls/playlists/vod.m3u8")! 46 | let reverseProxyURL = self.server.reverseProxyURL(from: originURL) 47 | expect(reverseProxyURL).to(beNil()) 48 | } 49 | 50 | func testPlaylist_requestsOriginalPlaylist() { 51 | // when 52 | let originURL = URL(string: "https://example.com/hls/playlists/vod.m3u8")! 53 | let url = self.server.reverseProxyURL(from: originURL)! 54 | 55 | let player = AVPlayer(url: url) 56 | player.play() 57 | 58 | // then 59 | expect(URLProtocolSpy.requests.first?.url).toEventually(equal(originURL)) 60 | } 61 | 62 | func testPlaylist_returnsPlaylistWithReverseProxyURLs() { 63 | // given 64 | var receivedPlaylist: String? 65 | 66 | // when 67 | let originURL = URL(string: "https://example.com/hls/playlists/vod.m3u8")! 68 | let url = self.server.reverseProxyURL(from: originURL)! 69 | 70 | let task = self.urlSession.dataTask(with: url) { data, response, error in 71 | guard let data = data else { return } 72 | receivedPlaylist = String(data: data, encoding: .utf8) 73 | } 74 | task.resume() 75 | 76 | // then 77 | expect(receivedPlaylist).toEventuallyNot(beNil()) 78 | let lines = receivedPlaylist?.components(separatedBy: .newlines) 79 | 80 | let englishAudioURI = "http://127.0.0.1:1234/hls/playlists/audio_en.m3u8?__hls_origin_url=https://example.com/hls/playlists/audio_en.m3u8" 81 | expect(lines?.safe[3]) == "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"audio\",NAME=\"English\",LANGUAGE=\"en\",AUTOSELECT=YES,URI=\"\(englishAudioURI)\"" 82 | 83 | let koreanAudioURI = "http://127.0.0.1:1234/hls/audio_ko.m3u8?__hls_origin_url=https://example.com/hls/audio_ko.m3u8" 84 | expect(lines?.safe[4]) == "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"audio\",NAME=\"Korean\",LANGUAGE=\"ko\",AUTOSELECT=YES,URI=\"\(koreanAudioURI)\"" 85 | 86 | let frenchAudioURI = "http://127.0.0.1:1234/audio_fr.m3u8?__hls_origin_url=https://example.com/audio_fr.m3u8" 87 | expect(lines?.safe[5]) == "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"audio\",NAME=\"French\",LANGUAGE=\"fr\",AUTOSELECT=YES,URI=\"\(frenchAudioURI)\"" 88 | 89 | let espanolAudioURI = "http://127.0.0.1:1234/audios/audio_es.m3u8?__hls_origin_url=https://example.com/audios/audio_es.m3u8" 90 | expect(lines?.safe[6]) == "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"audio\",NAME=\"Espanol\",LANGUAGE=\"es\",AUTOSELECT=YES,URI=\"\(espanolAudioURI)\"" 91 | 92 | expect(lines?.safe[10]) == "http://127.0.0.1:1234/hls/playlists/0640_00001.ts?__hls_origin_url=https://example.com/hls/playlists/0640_00001.ts" 93 | expect(lines?.safe[12]) == "http://127.0.0.1:1234/hls/0640_00002.ts?__hls_origin_url=https://example.com/hls/0640_00002.ts" 94 | expect(lines?.safe[14]) == "http://127.0.0.1:1234/0640_00003.ts?__hls_origin_url=https://example.com/0640_00003.ts" 95 | expect(lines?.safe[16]) == "http://127.0.0.1:1234/videos/0640_00004.ts?__hls_origin_url=https://example.com/videos/0640_00004.ts" 96 | } 97 | 98 | func testSegment_requestsOriginalSegments() { 99 | // when 100 | let originURL = URL(string: "https://example.com/hls/playlists/vod.m3u8")! 101 | let url = self.server.reverseProxyURL(from: originURL)! 102 | 103 | let player = AVPlayer(url: url) 104 | player.play() 105 | 106 | // then 107 | let urlStrings = { URLProtocolSpy.requests.compactMap { $0.url?.absoluteString } } 108 | expect(urlStrings()).toEventually(contain("https://example.com/hls/playlists/0640_00001.ts")) 109 | } 110 | 111 | func testSegment_returnsCacheIfExists() { 112 | // given 113 | self.cache.removeAllObjects() 114 | 115 | // when 116 | let originURL = URL(string: "https://example.com/hls/playlists/0640_00001.ts")! 117 | let url = self.server.reverseProxyURL(from: originURL)! 118 | 119 | var responseCount = 0 120 | self.urlSession.dataTask(with: url) { _, _, _ in responseCount += 1 }.resume() 121 | 122 | // run asynchronously; previous request needs time to cache 123 | DispatchQueue.main.async { 124 | self.urlSession.dataTask(with: url) { _, _, _ in responseCount += 1 }.resume() 125 | } 126 | 127 | // then 128 | expect(responseCount).toEventually(equal(2)) 129 | 130 | let urlStrings = URLProtocolSpy.requests.lazy 131 | .compactMap { $0.url?.absoluteString } 132 | .filter { $0 == "https://example.com/hls/playlists/0640_00001.ts" } 133 | expect(urlStrings).to(haveCount(1)) 134 | } 135 | } 136 | 137 | 138 | // MARK: - URLProtocolSpy 139 | 140 | private final class URLProtocolSpy: URLProtocol { 141 | static var requests: [URLRequest] = [] 142 | 143 | class func register() { 144 | URLProtocol.registerClass(Self.self) 145 | } 146 | 147 | class func unregister() { 148 | URLProtocol.unregisterClass(Self.self) 149 | self.requests.removeAll() 150 | } 151 | } 152 | 153 | extension URLProtocolSpy { 154 | override class func canInit(with request: URLRequest) -> Bool { 155 | return request.url?.host != "127.0.0.1" 156 | } 157 | 158 | override class func canInit(with task: URLSessionTask) -> Bool { 159 | return task.currentRequest?.url?.host != "127.0.0.1" 160 | } 161 | 162 | override class func canonicalRequest(for request: URLRequest) -> URLRequest { 163 | return request 164 | } 165 | 166 | override func startLoading() { 167 | Self.requests.append(self.request) 168 | 169 | switch self.request.url?.absoluteString { 170 | case "https://example.com/hls/playlists/vod.m3u8": 171 | let playlist = """ 172 | #EXTM3U 173 | #EXT-X-VERSION:3 174 | #EXT-X-TARGETDURATION:13 175 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="English",LANGUAGE="en",AUTOSELECT=YES,URI="audio_en.m3u8" 176 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="Korean",LANGUAGE="ko",AUTOSELECT=YES,URI="../audio_ko.m3u8" 177 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="French",LANGUAGE="fr",AUTOSELECT=YES,URI="/audio_fr.m3u8" 178 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="Espanol",LANGUAGE="es",AUTOSELECT=YES,URI="/audios/audio_es.m3u8" 179 | #EXT-X-MEDIA-SEQUENCE:1 180 | #EXT-X-PLAYLIST-TYPE:VOD 181 | #EXTINF:12.012, 182 | 0640_00001.ts 183 | #EXTINF:12.012, 184 | ../0640_00002.ts 185 | #EXTINF:12.012, 186 | /0640_00003.ts 187 | #EXTINF:12.012, 188 | /videos/0640_00004.ts 189 | #EXT-X-ENDLIST 190 | """ 191 | let data = playlist.data(using: .utf8)! 192 | let response = URLResponse(url: self.request.url!, mimeType: "application/x-mpegurl", expectedContentLength: data.count, textEncodingName: nil) 193 | self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .allowedInMemoryOnly) 194 | self.client?.urlProtocol(self, didLoad: data) 195 | self.client?.urlProtocolDidFinishLoading(self) 196 | 197 | case "https://example.com/hls/playlists/0640_00001.ts": 198 | let data = "abcdef".data(using: .utf8)! 199 | let response = URLResponse(url: self.request.url!, mimeType: "video/mp2t", expectedContentLength: data.count, textEncodingName: nil) 200 | self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .allowedInMemoryOnly) 201 | self.client?.urlProtocol(self, didLoad: data) 202 | self.client?.urlProtocolDidFinishLoading(self) 203 | 204 | default: 205 | self.client?.urlProtocol(self, didFailWithError: URLProtocolError.unhandled) 206 | } 207 | } 208 | 209 | override func stopLoading() { 210 | } 211 | 212 | enum URLProtocolError: Error { 213 | case unhandled 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "Tests/" 3 | 4 | coverage: 5 | status: 6 | project: no 7 | patch: no 8 | changes: no 9 | --------------------------------------------------------------------------------