1
1
import Foundation
2
+ import os. log
2
3
import Swifter
3
4
4
5
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 ? {
25
9
guard
26
10
let port = try ? self . port ( )
27
11
else {
28
12
return nil
29
13
}
30
14
return URL ( string: " http://localhost: \( port) / \( path) / \( livestreamPlaylistFilename) " )
31
15
}
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
32
22
33
23
/// The difference in seconds between the server time and the client time.
34
24
///
@@ -61,20 +51,127 @@ public final class HlsServer: HttpServer {
61
51
/// )
62
52
/// ```
63
53
/// 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
+ }
67
139
}
68
140
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
+
70
161
private let initialMediaSequence = 1_000_000
162
+ private var isTimerSuspended = true
163
+
164
+ private let livestreamVariantFilename = " main-128000-ios.m3u8 "
71
165
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 ) ) }
74
168
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 "
78
175
79
176
private lazy var timer : DispatchSourceTimer = {
80
177
let t = DispatchSource . makeTimerSource ( )
@@ -84,39 +181,25 @@ public final class HlsServer: HttpServer {
84
181
} )
85
182
return t
86
183
} ( )
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
- } ( )
96
184
97
185
@objc private func updatePlaylist( ) {
186
+ guard !isStale else { return }
98
187
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
106
189
107
190
// this is constant since segments are removed from the top and added to the bottom (5993 in our case)
108
191
let skippedSegments = segmentCount- skippableSegments- 1
109
192
110
193
var fullPlaylist = """
111
194
#EXTM3U
112
- #EXT-X-VERSION:3
195
+ #EXT-X-VERSION:9
113
196
#EXT-X-MEDIA-SEQUENCE: \( initialMediaSequence + numberOfPlaylistUpdates)
114
197
#EXT-X-TARGETDURATION: \( targetSegmentLength)
115
198
#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL= \( skippableSegments * targetSegmentLength)
116
199
"""
117
200
118
201
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
120
203
121
204
for segmentIndex in numberOfPlaylistUpdates ..< segmentCount + numberOfPlaylistUpdates {
122
205
let inverseIndex = segmentCount + numberOfPlaylistUpdates - segmentIndex - 1
@@ -145,19 +228,10 @@ public final class HlsServer: HttpServer {
145
228
numberOfPlaylistUpdates += 1
146
229
}
147
230
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
-
157
231
private func startScheduledPlaylistUpdates( ) {
158
232
guard isTimerSuspended else { return }
159
233
160
- print ( " playlist timer update started " )
234
+ os_log ( " playlist timer update started " , log : log , type : . info )
161
235
162
236
timer. resume ( )
163
237
isTimerSuspended. toggle ( )
@@ -166,73 +240,9 @@ public final class HlsServer: HttpServer {
166
240
private func stopScheduledPlaylistUpdates( ) {
167
241
guard !isTimerSuspended else { return }
168
242
169
- print ( " playlist timer update stopped " )
243
+ os_log ( " playlist timer update stopped " , log : log , type : . info )
170
244
timer. suspend ( )
171
245
}
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
- }
238
246
}
247
+
248
+ private let log = OSLog ( subsystem: " de.fruitco.hlsmock " , category: " server " )
0 commit comments