Skip to content

Commit fb6f77b

Browse files
committed
Code cleanup, readme, creative common segment
1 parent 90c1e65 commit fb6f77b

File tree

16 files changed

+152
-125
lines changed

16 files changed

+152
-125
lines changed

Example/main.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ let semaphore = DispatchSemaphore(value: 0)
88
do {
99
try server.start(9080, forceIPv4: true)
1010
print("Server has started ( port = \(try server.port()) ). Try to connect now...")
11+
print("Check out \(server.livestreamUrl!.absoluteString)")
12+
1113
semaphore.wait()
1214
} catch {
1315
print("Server start error: \(error)")

Package.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ import PackageDescription
55

66
let package = Package(
77
name: "SwifterHlsMock",
8+
platforms: [
9+
.iOS(.v10),
10+
.macOS(.v10_12),
11+
.tvOS(.v10),
12+
.watchOS(.v3)
13+
],
814
products: [
915
.library(
1016
name: "SwifterHlsMock",

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,11 @@
11
# SwiftHlsMock
2-
A little mock server to simulate hls locally based on Swifter
2+
A little mock server to simulate hls locally based on Swifter.
3+
4+
##### Why?
5+
6+
You can use this package to have better control over your UITests by just starting the server and giving your player the localhost url. The server always streams the same segment (the 10 segments are identical) from [bensound](https://www.bensound.com/royalty-free-music/track/ukulele).
7+
8+
##### Try it
9+
10+
To try it out I'd recommend checking out the project and starting the Sample executable.
11+
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Lines changed: 134 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,24 @@
11
import Foundation
2+
import os.log
23
import Swifter
34

45
public final class HlsServer: HttpServer {
5-
var targetSegmentLength: Int = 3
6-
let segmentLength: TimeInterval = 2.9866666
7-
let seekingWindowInSeconds: TimeInterval = 18_000 // 5h
8-
let skippableSegments = 6
9-
var isStale: Bool = false {
10-
didSet {
11-
guard isStale != oldValue else { return }
12-
13-
if isStale {
14-
stopScheduledPlaylistUpdates()
15-
} else {
16-
startScheduledPlaylistUpdates()
17-
}
18-
}
19-
}
20-
let path: String
21-
let segmentsPath: String = "segments"
22-
let livestreamPlaylistFilename = "main-ios.m3u8"
23-
let livestreamVariantFilename = "main-128000-ios.m3u8"
24-
var livestreamUrl: URL? {
6+
public var isStale: Bool = false
7+
public let livestreamPlaylistFilename: String
8+
public var livestreamUrl: URL? {
259
guard
2610
let port = try? self.port()
2711
else {
2812
return nil
2913
}
3014
return URL(string: "http://localhost:\(port)/\(path)/\(livestreamPlaylistFilename)")
3115
}
16+
public var now: Date {
17+
Date().addingTimeInterval(-serverToNowDifference)
18+
}
19+
public let path: String
20+
public let seekingWindowInSeconds: TimeInterval
21+
public let segmentLength: TimeInterval
3222

3323
/// The difference in seconds between the server time and the client time.
3424
///
@@ -61,20 +51,127 @@ public final class HlsServer: HttpServer {
6151
/// )
6252
/// ```
6353
/// This way the time passes in realtime relative to a fixed start date.
64-
let serverToNowDifference: TimeInterval
65-
var now: Date {
66-
Date().addingTimeInterval(-serverToNowDifference)
54+
public let serverToNowDifference: TimeInterval
55+
56+
public let skippableSegments: Int
57+
public let targetSegmentLength: Int
58+
59+
/// Creates an `HlsServer` instance.
60+
/// - Parameters:
61+
/// - livestreamPlaylistFilename: The name of the playlist. Defaults to `main-ios.m3u8`.
62+
/// - path: The path to register for hls. Defaults to `mockServer`.
63+
/// - seekingWindowInSeconds: The seeking in window in seconds. Defaults to five hours.
64+
/// - segmentLength: The actual segment length in seconds. Defaults to `2.9866666`.
65+
/// - skippableSegments: This value + 1 determines the number of segments returned for delta updates. Defaults to `6`.
66+
/// - targetSegmentLength: The target duration of a segment (`#EXT-X-TARGETDURATION`). Defaults to `3`.
67+
/// - serverToNowDifference: The time difference between the client and the server. Defaults to `0`. See ``serverToNowDifference``.
68+
public init(livestreamPlaylistFilename: String = "main-ios.m3u8",
69+
path: String = "mockServer",
70+
seekingWindowInSeconds: TimeInterval = 18_000,
71+
segmentLength: TimeInterval = 2.9866666,
72+
skippableSegments: Int = 6,
73+
targetSegmentLength: Int = 3,
74+
serverToNowDifference: TimeInterval = 0) {
75+
self.livestreamPlaylistFilename = livestreamPlaylistFilename
76+
self.path = path
77+
self.seekingWindowInSeconds = seekingWindowInSeconds
78+
self.segmentLength = segmentLength
79+
self.skippableSegments = skippableSegments
80+
self.targetSegmentLength = targetSegmentLength
81+
self.serverToNowDifference = serverToNowDifference
82+
83+
super.init()
84+
85+
startScheduledPlaylistUpdates()
86+
87+
// segments
88+
self["/\(path)/\(segmentsPath)/:segmentName"] = { request in
89+
os_log("request path is %{public}@", log: log, type: .info, request.path)
90+
91+
let filePath = Bundle.module.resourcePath! + "/segments/sample\(self.segmentFileIndex).ts"
92+
93+
os_log("streaming file from %{public}@", log: log, type: .info, filePath)
94+
95+
if let file = try? filePath.openForReading() {
96+
var responseHeader: [String: String] = ["Content-Type": "video/mp2t"]
97+
98+
if let attr = try? FileManager.default.attributesOfItem(atPath: filePath),
99+
let fileSize = attr[FileAttributeKey.size] as? UInt64 {
100+
responseHeader["Content-Length"] = String(fileSize)
101+
}
102+
103+
return .raw(200, "OK", responseHeader, { writer in
104+
try? writer.write(file)
105+
file.close()
106+
})
107+
}
108+
return .notFound
109+
}
110+
111+
self["/\(path)/\(livestreamPlaylistFilename)"] = { request in
112+
os_log("request main playlist", log: log, type: .info)
113+
114+
return .ok(
115+
.data("""
116+
#EXTM3U
117+
#EXT-X-VERSION:9
118+
#EXT-X-ALLOW-CACHE:NO
119+
## Created with Z/IPStream R/2 v1.08.09
120+
#EXT-X-STREAM-INF:BANDWIDTH=137557,CODECS="mp4a.40.2"
121+
\(self.livestreamVariantFilename)
122+
""".data(using: .utf8)!,
123+
contentType: "application/vnd.apple.mpegurl"
124+
)
125+
)
126+
}
127+
128+
self["/\(path)/\(livestreamVariantFilename)"] = { [weak self] request in
129+
guard let self = self else { return .internalServerError }
130+
131+
if request.queryParams.contains(where: { (key, value) in key == "_HLS_skip" && value == "YES" }) {
132+
os_log("requesting delta variant playlist", log: log, type: .info)
133+
return .ok(.data(self.currentDeltaPlaylist.data(using: .utf8)!, contentType: "application/vnd.apple.mpegurl"))
134+
} else {
135+
os_log("requesting variant playlist", log: log, type: .info)
136+
return .ok(.data(self.currentPlaylist.data(using: .utf8)!, contentType: "application/vnd.apple.mpegurl"))
137+
}
138+
}
67139
}
68140

69-
private var segmentCount: Int { Int(seekingWindowInSeconds / Double(targetSegmentLength)) }
141+
deinit {
142+
timer.setEventHandler {}
143+
timer.cancel()
144+
145+
// If the timer is suspended, calling cancel without resuming triggers a crash.
146+
// This is documented here https://forums.developer.apple.com/thread/15902
147+
startScheduledPlaylistUpdates()
148+
}
149+
150+
private var currentDeltaPlaylist: String = ""
151+
private var currentPlaylist: String = ""
152+
153+
private lazy var dateFormatter: DateFormatter = {
154+
let formatter = DateFormatter()
155+
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
156+
formatter.locale = Locale(identifier: "en_US_POSIX")
157+
formatter.timeZone = TimeZone(secondsFromGMT: 0)
158+
return formatter
159+
}()
160+
70161
private let initialMediaSequence = 1_000_000
162+
private var isTimerSuspended = true
163+
164+
private let livestreamVariantFilename = "main-128000-ios.m3u8"
71165
private var numberOfPlaylistUpdates = 0
72-
private var currentPlaylist: String = ""
73-
private var currentDeltaPlaylist: String = ""
166+
private var previousSegmentIndex = 0
167+
private var segmentCount: Int { Int(seekingWindowInSeconds / Double(targetSegmentLength)) }
74168

75-
private var previousDeltaPlaylistResponse: String?
76-
private var previousPlaylistResponse: String?
77-
private var previousDate: Date?
169+
private var segmentFileIndex: Int {
170+
defer { previousSegmentIndex = (previousSegmentIndex + 1) % segmentIndices.count }
171+
return segmentIndices[previousSegmentIndex]
172+
}
173+
private var segmentIndices = Array(0...9)
174+
private let segmentsPath = "segments"
78175

79176
private lazy var timer: DispatchSourceTimer = {
80177
let t = DispatchSource.makeTimerSource()
@@ -84,39 +181,25 @@ public final class HlsServer: HttpServer {
84181
})
85182
return t
86183
}()
87-
private var isTimerSuspended = true
88-
89-
private lazy var dateFormatter: DateFormatter = {
90-
let formatter = DateFormatter()
91-
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
92-
formatter.locale = Locale(identifier: "en_US_POSIX")
93-
formatter.timeZone = TimeZone(secondsFromGMT: 0)
94-
return formatter
95-
}()
96184

97185
@objc private func updatePlaylist() {
186+
guard !isStale else { return }
98187

99-
// if currentMediaSequence >= 1_000_010 {
100-
// print("playlist is stale now")
101-
// return
102-
// }
103-
104-
// simulate one segment length latency to the live time
105-
let mostRecentSegmentPDT = now.addingTimeInterval(-segmentLength)
188+
let mostRecentSegmentPDT = now
106189

107190
// this is constant since segments are removed from the top and added to the bottom (5993 in our case)
108191
let skippedSegments = segmentCount-skippableSegments-1
109192

110193
var fullPlaylist = """
111194
#EXTM3U
112-
#EXT-X-VERSION:3
195+
#EXT-X-VERSION:9
113196
#EXT-X-MEDIA-SEQUENCE:\(initialMediaSequence + numberOfPlaylistUpdates)
114197
#EXT-X-TARGETDURATION:\(targetSegmentLength)
115198
#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=\(skippableSegments * targetSegmentLength)
116199
"""
117200

118201
var deltaPlayist = fullPlaylist
119-
deltaPlayist += "\n#EXT-X-SKIP:SKIPPED-SEGMENTS=\(skippedSegments)\n" // this is the total number of skipped segments
202+
deltaPlayist += "\n#EXT-X-SKIP:SKIPPED-SEGMENTS=\(skippedSegments)" // this is the total number of skipped segments
120203

121204
for segmentIndex in numberOfPlaylistUpdates ..< segmentCount + numberOfPlaylistUpdates {
122205
let inverseIndex = segmentCount + numberOfPlaylistUpdates - segmentIndex - 1
@@ -145,19 +228,10 @@ public final class HlsServer: HttpServer {
145228
numberOfPlaylistUpdates += 1
146229
}
147230

148-
deinit {
149-
timer.setEventHandler {}
150-
timer.cancel()
151-
152-
// If the timer is suspended, calling cancel without resuming triggers a crash.
153-
// This is documented here https://forums.developer.apple.com/thread/15902
154-
startScheduledPlaylistUpdates()
155-
}
156-
157231
private func startScheduledPlaylistUpdates() {
158232
guard isTimerSuspended else { return }
159233

160-
print("playlist timer update started")
234+
os_log("playlist timer update started", log: log, type: .info)
161235

162236
timer.resume()
163237
isTimerSuspended.toggle()
@@ -166,73 +240,9 @@ public final class HlsServer: HttpServer {
166240
private func stopScheduledPlaylistUpdates() {
167241
guard !isTimerSuspended else { return }
168242

169-
print("playlist timer update stopped")
243+
os_log("playlist timer update stopped", log: log, type: .info)
170244
timer.suspend()
171245
}
172-
173-
public init(path: String = "mockServer",
174-
serverToNowDifference: TimeInterval = 0) {
175-
self.path = path
176-
self.serverToNowDifference = serverToNowDifference
177-
178-
super.init()
179-
180-
startScheduledPlaylistUpdates()
181-
182-
// segments
183-
self["/\(path)/\(segmentsPath)/:segmentName"] = { request in
184-
print("request path is \(request.path)")
185-
186-
let filePath = Bundle.module.resourcePath! + "/segments/sample.ts"
187-
188-
if let file = try? filePath.openForReading() {
189-
var responseHeader: [String: String] = ["Content-Type": "video/mp2t"]
190-
191-
if let attr = try? FileManager.default.attributesOfItem(atPath: filePath),
192-
let fileSize = attr[FileAttributeKey.size] as? UInt64 {
193-
responseHeader["Content-Length"] = String(fileSize)
194-
}
195-
196-
return .raw(200, "OK", responseHeader, { writer in
197-
try? writer.write(file)
198-
file.close()
199-
})
200-
}
201-
return .notFound
202-
}
203-
204-
self["/\(path)/\(livestreamPlaylistFilename)"] = { request in
205-
return .ok(
206-
.data("""
207-
#EXTM3U
208-
#EXT-X-VERSION:3
209-
#EXT-X-ALLOW-CACHE:NO
210-
## Created with Z/IPStream R/2 v1.08.09
211-
#EXT-X-STREAM-INF:BANDWIDTH=137557,CODECS="mp4a.40.2"
212-
\(self.livestreamVariantFilename)
213-
""".data(using: .utf8)!,
214-
contentType: "application/vnd.apple.mpegurl"
215-
)
216-
)
217-
}
218-
219-
self["/\(path)/\(livestreamVariantFilename)"] = { [weak self] request in
220-
guard let self = self else { return .internalServerError }
221-
222-
if request.queryParams.contains(where: { (key, value) in key == "_HLS_skip" && value == "YES" }) {
223-
let response = self.currentDeltaPlaylist
224-
self.previousDeltaPlaylistResponse = response
225-
return .ok(.data(response.data(using: .utf8)!, contentType: "application/vnd.apple.mpegurl"))
226-
} else {
227-
let response = self.currentPlaylist
228-
self.previousPlaylistResponse = response
229-
return .ok(.data(response.data(using: .utf8)!, contentType: "application/vnd.apple.mpegurl"))
230-
}
231-
}
232-
233-
self.notFoundHandler = { request in
234-
print("Not found handler called \(dump(request))")
235-
return .notFound
236-
}
237-
}
238246
}
247+
248+
private let log = OSLog(subsystem: "de.fruitco.hlsmock", category: "server")

0 commit comments

Comments
 (0)