mirror of
https://github.com/immich-app/immich.git
synced 2026-03-23 13:29:45 +03:00
add schemas sync variants formatting initial implementation use existing db, wip move to separate folder fix table definitions wip wiring it up repository pattern
215 lines
7.4 KiB
Swift
215 lines
7.4 KiB
Swift
import SQLiteData
|
|
|
|
private let stateLock = NSLock()
|
|
private var transferStates: [Int64: NetworkTransferState] = [:]
|
|
private var responseData: [Int64: Data] = [:]
|
|
private let jsonDecoder = JSONDecoder()
|
|
|
|
private class NetworkTransferState {
|
|
var lastUpdateTime: Date
|
|
var totalBytesTransferred: Int64
|
|
var currentSpeed: Double?
|
|
|
|
init(lastUpdateTime: Date, totalBytesTransferred: Int64, currentSpeed: Double?) {
|
|
self.lastUpdateTime = lastUpdateTime
|
|
self.totalBytesTransferred = totalBytesTransferred
|
|
self.currentSpeed = currentSpeed
|
|
}
|
|
}
|
|
|
|
final class UploadApiDelegate<
|
|
TaskRepo: TaskProtocol,
|
|
StatusListener: TaskStatusListener,
|
|
ProgressListener: TaskProgressListener
|
|
>: NSObject, URLSessionDataDelegate, URLSessionTaskDelegate {
|
|
private let taskRepository: TaskRepo
|
|
private let statusListener: StatusListener
|
|
private let progressListener: ProgressListener
|
|
|
|
init(taskRepository: TaskRepo, statusListener: StatusListener, progressListener: ProgressListener) {
|
|
self.taskRepository = taskRepository
|
|
self.statusListener = statusListener
|
|
self.progressListener = progressListener
|
|
}
|
|
|
|
static func reset() {
|
|
stateLock.withLock {
|
|
transferStates.removeAll()
|
|
responseData.removeAll()
|
|
}
|
|
}
|
|
|
|
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
|
guard let taskIdStr = dataTask.taskDescription,
|
|
let taskId = Int64(taskIdStr)
|
|
else { return }
|
|
|
|
stateLock.withLock {
|
|
if var response = responseData[taskId] {
|
|
response.append(data)
|
|
} else {
|
|
responseData[taskId] = data
|
|
}
|
|
}
|
|
}
|
|
|
|
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
|
Task {
|
|
defer {
|
|
NotificationCenter.default.post(name: .uploadTaskDidComplete, object: nil)
|
|
}
|
|
|
|
guard let taskDescriptionId = task.taskDescription,
|
|
let taskId = Int64(taskDescriptionId)
|
|
else {
|
|
return dPrint("Unexpected: task without session ID completed")
|
|
}
|
|
|
|
defer {
|
|
stateLock.withLock { let _ = transferStates.removeValue(forKey: taskId) }
|
|
}
|
|
|
|
if let body = stateLock.withLock({ responseData.removeValue(forKey: taskId) }),
|
|
let response = task.response as? HTTPURLResponse
|
|
{
|
|
switch response.statusCode {
|
|
case 200, 201:
|
|
do {
|
|
let response = try jsonDecoder.decode(UploadSuccessResponse.self, from: body)
|
|
return await handleSuccess(taskId: taskId, response: response)
|
|
} catch {
|
|
return await handleFailure(taskId: taskId, code: .invalidResponse)
|
|
}
|
|
case 401: return await handleFailure(taskId: taskId, code: .unauthorized)
|
|
case 400..<500:
|
|
dPrint("Response \(response.statusCode): \(String(data: body, encoding: .utf8) ?? "No response body")")
|
|
return await handleFailure(taskId: taskId, code: .badRequest)
|
|
case 500..<600:
|
|
dPrint("Response \(response.statusCode): \(String(data: body, encoding: .utf8) ?? "No response body")")
|
|
return await handleFailure(taskId: taskId, code: .internalServerError)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
guard let urlError = error as? URLError else {
|
|
return await handleFailure(taskId: taskId)
|
|
}
|
|
|
|
if #available(iOS 17, *), let resumeData = urlError.uploadTaskResumeData {
|
|
return await handleFailure(taskDescriptionId: taskDescriptionId, session: session, resumeData: resumeData)
|
|
}
|
|
|
|
let code: UploadErrorCode =
|
|
switch urlError.backgroundTaskCancelledReason {
|
|
case .backgroundUpdatesDisabled: .backgroundUpdatesDisabled
|
|
case .insufficientSystemResources: .outOfResources
|
|
case .userForceQuitApplication: .forceQuit
|
|
default:
|
|
switch urlError.code {
|
|
case .networkConnectionLost, .notConnectedToInternet: .networkError
|
|
case .timedOut: .uploadTimeout
|
|
case .resourceUnavailable, .fileDoesNotExist: .fileNotFound
|
|
default: .unknown
|
|
}
|
|
}
|
|
await handleFailure(taskId: taskId, code: code)
|
|
}
|
|
}
|
|
|
|
func urlSession(
|
|
_ session: URLSession,
|
|
task: URLSessionTask,
|
|
didSendBodyData bytesSent: Int64,
|
|
totalBytesSent: Int64,
|
|
totalBytesExpectedToSend: Int64
|
|
) {
|
|
guard let sessionTaskId = task.taskDescription, let taskId = Int64(sessionTaskId) else { return }
|
|
let currentTime = Date()
|
|
let state = stateLock.withLock {
|
|
if let existing = transferStates[taskId] {
|
|
return existing
|
|
}
|
|
let new = NetworkTransferState(
|
|
lastUpdateTime: currentTime,
|
|
totalBytesTransferred: totalBytesSent,
|
|
currentSpeed: nil
|
|
)
|
|
transferStates[taskId] = new
|
|
return new
|
|
}
|
|
|
|
let timeDelta = currentTime.timeIntervalSince(state.lastUpdateTime)
|
|
guard timeDelta > 0 else { return }
|
|
|
|
let bytesDelta = totalBytesSent - state.totalBytesTransferred
|
|
let instantSpeed = Double(bytesDelta) / timeDelta
|
|
let currentSpeed =
|
|
if let previousSpeed = state.currentSpeed {
|
|
TaskConfig.transferSpeedAlpha * instantSpeed + (1 - TaskConfig.transferSpeedAlpha) * previousSpeed
|
|
} else {
|
|
instantSpeed
|
|
}
|
|
state.currentSpeed = currentSpeed
|
|
state.lastUpdateTime = currentTime
|
|
state.totalBytesTransferred = totalBytesSent
|
|
self.progressListener.onTaskProgress(
|
|
UploadApiTaskProgress(
|
|
id: sessionTaskId,
|
|
progress: Double(totalBytesSent) / Double(totalBytesExpectedToSend),
|
|
speed: currentSpeed
|
|
)
|
|
)
|
|
}
|
|
|
|
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
|
|
dPrint("All background events delivered for session: \(session.configuration.identifier ?? "unknown")")
|
|
DispatchQueue.main.async {
|
|
if let identifier = session.configuration.identifier,
|
|
let appDelegate = UIApplication.shared.delegate as? AppDelegate,
|
|
let completionHandler = appDelegate.completionHandler(forSession: identifier)
|
|
{
|
|
completionHandler()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleSuccess(taskId: Int64, response: UploadSuccessResponse) async {
|
|
dPrint("Upload succeeded for task \(taskId), server ID: \(response.id)")
|
|
do {
|
|
try await taskRepository.markUploadSuccess(taskId: taskId, livePhotoVideoId: response.id)
|
|
statusListener.onTaskStatus(
|
|
UploadApiTaskStatus(
|
|
id: String(taskId),
|
|
filename: (try? await taskRepository.getFilename(taskId: taskId)) ?? "",
|
|
status: .uploadComplete
|
|
)
|
|
)
|
|
} catch {
|
|
dPrint(
|
|
"Failed to update upload success status for session task \(taskId): \(error.localizedDescription)"
|
|
)
|
|
}
|
|
}
|
|
|
|
private func handleFailure(taskId: Int64, code: UploadErrorCode = .unknown) async {
|
|
dPrint("Upload failed for task \(taskId) with code \(code)")
|
|
try? await taskRepository.retryOrFail(taskId: taskId, code: code, status: .uploadFailed)
|
|
statusListener.onTaskStatus(
|
|
UploadApiTaskStatus(
|
|
id: String(taskId),
|
|
filename: (try? await taskRepository.getFilename(taskId: taskId)) ?? "",
|
|
status: .uploadFailed
|
|
)
|
|
)
|
|
}
|
|
|
|
@available(iOS 17, *)
|
|
private func handleFailure(taskDescriptionId: String, session: URLSession, resumeData: Data) async {
|
|
dPrint("Resuming upload for task \(taskDescriptionId)")
|
|
let resumeTask = session.uploadTask(withResumeData: resumeData)
|
|
resumeTask.taskDescription = taskDescriptionId
|
|
resumeTask.resume()
|
|
}
|
|
}
|