@@ -11,6 +11,7 @@ import Foundation
11
11
12
12
class Downloader : NSObject , ObservableObject {
13
13
private( set) var destination : URL
14
+ private( set) var sourceURL : URL
14
15
15
16
private let chunkSize = 10 * 1024 * 1024 // 10MB
16
17
@@ -24,25 +25,69 @@ class Downloader: NSObject, ObservableObject {
24
25
enum DownloadError : Error {
25
26
case invalidDownloadLocation
26
27
case unexpectedError
28
+ case tempFileNotFound
27
29
}
28
30
29
31
private( set) lazy var downloadState : CurrentValueSubject < DownloadState , Never > = CurrentValueSubject ( . notStarted)
30
32
private var stateSubscriber : Cancellable ?
33
+
34
+ private( set) var tempFilePath : URL ?
35
+ private( set) var expectedSize : Int ?
36
+ private( set) var downloadedSize : Int = 0
31
37
32
38
private var urlSession : URLSession ? = nil
39
+
40
+ /// Creates the incomplete file path for a given destination URL
41
+ /// This is similar to the Hugging Face Hub approach of using .incomplete files
42
+ static func incompletePath( for destination: URL ) -> URL {
43
+ destination. appendingPathExtension ( " incomplete " )
44
+ }
45
+
46
+ /// Check if an incomplete file exists for the destination and returns its size
47
+ /// - Parameter destination: The destination URL for the download
48
+ /// - Returns: Size of the incomplete file if it exists, otherwise 0
49
+ static func checkForIncompleteFile( at destination: URL ) -> Int {
50
+ let incompletePath = Self . incompletePath ( for: destination)
51
+
52
+ if FileManager . default. fileExists ( atPath: incompletePath. path) {
53
+ if let attributes = try ? FileManager . default. attributesOfItem ( atPath: incompletePath. path) ,
54
+ let fileSize = attributes [ . size] as? Int
55
+ {
56
+ print ( " [Downloader] Found existing incomplete file for \( destination. lastPathComponent) : \( fileSize) bytes " )
57
+ return fileSize
58
+ }
59
+ }
60
+
61
+ return 0
62
+ }
33
63
34
64
init (
35
65
from url: URL ,
36
66
to destination: URL ,
37
67
using authToken: String ? = nil ,
38
68
inBackground: Bool = false ,
39
- resumeSize: Int = 0 ,
69
+ resumeSize: Int = 0 , // Can be specified manually, but will also check for incomplete files
40
70
headers: [ String : String ] ? = nil ,
41
71
expectedSize: Int ? = nil ,
42
72
timeout: TimeInterval = 10 ,
43
73
numRetries: Int = 5
44
74
) {
45
75
self . destination = destination
76
+ sourceURL = url
77
+ self . expectedSize = expectedSize
78
+
79
+ // Create incomplete file path based on destination
80
+ tempFilePath = Downloader . incompletePath ( for: destination)
81
+
82
+ // If resume size wasn't specified, check for an existing incomplete file
83
+ let actualResumeSize : Int = if resumeSize > 0 {
84
+ resumeSize
85
+ } else {
86
+ Downloader . checkForIncompleteFile ( at: destination)
87
+ }
88
+
89
+ downloadedSize = actualResumeSize
90
+
46
91
super. init ( )
47
92
let sessionIdentifier = " swift-transformers.hub.downloader "
48
93
@@ -55,7 +100,7 @@ class Downloader: NSObject, ObservableObject {
55
100
56
101
urlSession = URLSession ( configuration: config, delegate: self , delegateQueue: nil )
57
102
58
- setupDownload ( from: url, with: authToken, resumeSize: resumeSize , headers: headers, expectedSize: expectedSize, timeout: timeout, numRetries: numRetries)
103
+ setUpDownload ( from: url, with: authToken, resumeSize: actualResumeSize , headers: headers, expectedSize: expectedSize, timeout: timeout, numRetries: numRetries)
59
104
}
60
105
61
106
/// Sets up and initiates a file download operation
@@ -68,7 +113,7 @@ class Downloader: NSObject, ObservableObject {
68
113
/// - expectedSize: Expected file size in bytes for validation
69
114
/// - timeout: Time interval before the request times out
70
115
/// - numRetries: Number of retry attempts for failed downloads
71
- private func setupDownload (
116
+ private func setUpDownload (
72
117
from url: URL ,
73
118
with authToken: String ? ,
74
119
resumeSize: Int ,
@@ -77,61 +122,101 @@ class Downloader: NSObject, ObservableObject {
77
122
timeout: TimeInterval ,
78
123
numRetries: Int
79
124
) {
80
- downloadState. value = . downloading( 0 )
125
+ print ( " [Downloader] Setting up download for \( url. lastPathComponent) " )
126
+ print ( " [Downloader] Destination: \( destination. path) " )
127
+ print ( " [Downloader] Incomplete file: \( tempFilePath? . path ?? " none " ) " )
128
+
81
129
urlSession? . getAllTasks { tasks in
82
130
// If there's an existing pending background task with the same URL, let it proceed.
83
131
if let existing = tasks. filter ( { $0. originalRequest? . url == url } ) . first {
84
132
switch existing. state {
85
133
case . running:
86
- // print("Already downloading \(url)")
134
+ print ( " [Downloader] Task already running for \( url. lastPathComponent ) " )
87
135
return
88
136
case . suspended:
89
- // print("Resuming suspended download task for \(url)")
137
+ print ( " [Downloader] Resuming suspended download task for \( url. lastPathComponent ) " )
90
138
existing. resume ( )
91
139
return
92
- case . canceling:
93
- // print("Starting new download task for \(url), previous was canceling")
94
- break
95
- case . completed:
96
- // print("Starting new download task for \(url), previous is complete but the file is no longer present (I think it's cached)")
97
- break
140
+ case . canceling, . completed:
141
+ existing. cancel ( )
98
142
@unknown default :
99
- // print("Unknown state for running task; cancelling and creating a new one")
100
143
existing. cancel ( )
101
144
}
102
145
}
103
- var request = URLRequest ( url: url)
104
-
105
- // Use headers from argument else create an empty header dictionary
106
- var requestHeaders = headers ?? [ : ]
107
-
108
- // Populate header auth and range fields
109
- if let authToken {
110
- requestHeaders [ " Authorization " ] = " Bearer \( authToken) "
111
- }
112
- if resumeSize > 0 {
113
- requestHeaders [ " Range " ] = " bytes= \( resumeSize) - "
114
- }
115
146
116
- request. timeoutInterval = timeout
117
- request. allHTTPHeaderFields = requestHeaders
118
-
119
147
Task {
120
148
do {
121
- // Create a temp file to write
122
- let tempURL = FileManager . default. temporaryDirectory. appendingPathComponent ( UUID ( ) . uuidString)
123
- FileManager . default. createFile ( atPath: tempURL. path, contents: nil )
124
- let tempFile = try FileHandle ( forWritingTo: tempURL)
149
+ // Check if incomplete file exists and get its size
150
+ var existingSize = 0
151
+ guard let incompleteFilePath = self . tempFilePath else {
152
+ throw DownloadError . unexpectedError
153
+ }
154
+
155
+ let fileManager = FileManager . default
156
+ if fileManager. fileExists ( atPath: incompleteFilePath. path) {
157
+ let attributes = try fileManager. attributesOfItem ( atPath: incompleteFilePath. path)
158
+ existingSize = attributes [ . size] as? Int ?? 0
159
+ print ( " [Downloader] Found incomplete file with \( existingSize) bytes " )
160
+ self . downloadedSize = existingSize
161
+ } else {
162
+ // Create parent directory if needed
163
+ try fileManager. createDirectory ( at: incompleteFilePath. deletingLastPathComponent ( ) , withIntermediateDirectories: true )
164
+
165
+ // Create empty incomplete file
166
+ fileManager. createFile ( atPath: incompleteFilePath. path, contents: nil )
167
+ print ( " [Downloader] Created new incomplete file at \( incompleteFilePath. path) " )
168
+ }
169
+
170
+ // Set up the request with appropriate headers
171
+ var request = URLRequest ( url: url)
172
+ var requestHeaders = headers ?? [ : ]
173
+
174
+ if let authToken {
175
+ requestHeaders [ " Authorization " ] = " Bearer \( authToken) "
176
+ }
177
+
178
+ // Set Range header if we're resuming
179
+ if existingSize > 0 {
180
+ requestHeaders [ " Range " ] = " bytes= \( existingSize) - "
181
+
182
+ // Calculate and show initial progress
183
+ if let expectedSize, expectedSize > 0 {
184
+ let initialProgress = Double ( existingSize) / Double( expectedSize)
185
+ self . downloadState. value = . downloading( initialProgress)
186
+ print ( " [Downloader] Resuming from \( existingSize) / \( expectedSize) bytes ( \( Int ( initialProgress * 100 ) ) %) " )
187
+ } else {
188
+ self . downloadState. value = . downloading( 0 )
189
+ print ( " [Downloader] Resuming download from byte \( existingSize) " )
190
+ }
191
+ } else {
192
+ self . downloadState. value = . downloading( 0 )
193
+ print ( " [Downloader] Starting new download " )
194
+ }
195
+
196
+ request. timeoutInterval = timeout
197
+ request. allHTTPHeaderFields = requestHeaders
198
+
199
+ // Open the incomplete file for writing
200
+ let tempFile = try FileHandle ( forWritingTo: incompleteFilePath)
201
+
202
+ // If resuming, seek to end of file
203
+ if existingSize > 0 {
204
+ try tempFile. seekToEnd ( )
205
+ }
125
206
126
207
defer { tempFile. closeFile ( ) }
127
- try await self . httpGet ( request: request, tempFile: tempFile, resumeSize: resumeSize , numRetries: numRetries, expectedSize: expectedSize)
208
+ try await self . httpGet ( request: request, tempFile: tempFile, resumeSize: self . downloadedSize , numRetries: numRetries, expectedSize: expectedSize)
128
209
129
210
// Clean up and move the completed download to its final destination
130
211
tempFile. closeFile ( )
131
- try FileManager . default. moveDownloadedFile ( from: tempURL, to: self . destination)
212
+ print ( " [Downloader] Download completed with total size \( self . downloadedSize) bytes " )
213
+ print ( " [Downloader] Moving incomplete file to destination: \( self . destination. path) " )
214
+ try fileManager. moveDownloadedFile ( from: incompleteFilePath, to: self . destination)
132
215
216
+ print ( " [Downloader] Download successfully completed " )
133
217
self . downloadState. value = . completed( self . destination)
134
218
} catch {
219
+ print ( " [Downloader] Error: \( error) " )
135
220
self . downloadState. value = . failed( error)
136
221
}
137
222
}
@@ -164,20 +249,31 @@ class Downloader: NSObject, ObservableObject {
164
249
var newRequest = request
165
250
if resumeSize > 0 {
166
251
newRequest. setValue ( " bytes= \( resumeSize) - " , forHTTPHeaderField: " Range " )
252
+ print ( " [Downloader] Adding Range header: bytes= \( resumeSize) - " )
167
253
}
168
254
169
255
// Start the download and get the byte stream
170
256
let ( asyncBytes, response) = try await session. bytes ( for: newRequest)
171
257
172
- guard let response = response as? HTTPURLResponse else {
258
+ guard let httpResponse = response as? HTTPURLResponse else {
259
+ print ( " [Downloader] Error: Non-HTTP response received " )
173
260
throw DownloadError . unexpectedError
174
261
}
262
+
263
+ print ( " [Downloader] Received HTTP \( httpResponse. statusCode) response " )
264
+ if let contentRange = httpResponse. value ( forHTTPHeaderField: " Content-Range " ) {
265
+ print ( " [Downloader] Content-Range: \( contentRange) " )
266
+ }
267
+ if let contentLength = httpResponse. value ( forHTTPHeaderField: " Content-Length " ) {
268
+ print ( " [Downloader] Content-Length: \( contentLength) " )
269
+ }
175
270
176
- guard ( 200 ..< 300 ) . contains ( response. statusCode) else {
271
+ guard ( 200 ..< 300 ) . contains ( httpResponse. statusCode) else {
272
+ print ( " [Downloader] Error: HTTP status code \( httpResponse. statusCode) " )
177
273
throw DownloadError . unexpectedError
178
274
}
179
275
180
- var downloadedSize = resumeSize
276
+ downloadedSize = resumeSize
181
277
182
278
// Create a buffer to collect bytes before writing to disk
183
279
var buffer = Data ( capacity: chunkSize)
@@ -218,7 +314,7 @@ class Downloader: NSObject, ObservableObject {
218
314
try await httpGet (
219
315
request: request,
220
316
tempFile: tempFile,
221
- resumeSize: downloadedSize,
317
+ resumeSize: self . downloadedSize,
222
318
numRetries: newNumRetries - 1 ,
223
319
expectedSize: expectedSize
224
320
)
@@ -227,7 +323,10 @@ class Downloader: NSObject, ObservableObject {
227
323
// Verify the downloaded file size matches the expected size
228
324
let actualSize = try tempFile. seekToEnd ( )
229
325
if let expectedSize, expectedSize != actualSize {
326
+ print ( " [Downloader] Error: Size mismatch - expected \( expectedSize) bytes but got \( actualSize) bytes " )
230
327
throw DownloadError . unexpectedError
328
+ } else {
329
+ print ( " [Downloader] Final verification passed, size: \( actualSize) bytes " )
231
330
}
232
331
}
233
332
0 commit comments