// // CameraTransferPipeline.swift // suixinkan // // Created by Codex on 2026/6/29. // import Foundation /// 相机传输编排器,负责监听新照片、下载到本地并按需调用上传 Sink。 @MainActor final class CameraTransferPipeline { var onTasksUpdated: (([CameraTransferTask]) -> Void)? private(set) var tasks: [CameraTransferTask] = [] private let cameraService: any CameraServiceProtocol private var uploadSink: (any CameraAssetUploadSink)? private var uploadEnabled = true private var isProcessing = false private var inFlightAssetIDs: Set = [] private let maxRetries = 3 /// 初始化传输管道,并注入相机服务。 init(cameraService: any CameraServiceProtocol) { self.cameraService = cameraService cameraService.onNewAsset = { [weak self] asset in Task { @MainActor in await self?.handleNewAsset(asset, skipIfExists: false) } } } /// 配置上传 Sink 与是否自动上传。 func configure(uploadSink: (any CameraAssetUploadSink)?, uploadEnabled: Bool) { self.uploadSink = uploadSink self.uploadEnabled = uploadEnabled } /// 启动相机连接。 func connect() async { await cameraService.connect() } /// 断开相机连接。 func disconnect() async { await cameraService.disconnect() } /// 当前相机连接状态。 var connectionState: CameraConnectionState { cameraService.connectionState } /// 设置连接状态变化回调。 var onConnectionStateChange: ((CameraConnectionState) -> Void)? { get { nil } set { cameraService.onConnectionStateChange = newValue } } /// 重试所有失败的上传任务。 func retryFailedUploads() async { guard uploadEnabled, uploadSink != nil else { return } for index in tasks.indices where tasks[index].status == .failed { tasks[index].status = .downloaded tasks[index].errorMessage = nil } notify() await processUploadQueue() } /// 手动上传指定 asset 列表(拍完再传模式)。 func uploadAssets(withIDs assetIDs: [String]) async { guard uploadEnabled, uploadSink != nil else { return } for assetID in assetIDs { guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { continue } if tasks[index].status == .uploaded { continue } if tasks[index].localURL == nil { await downloadAsset(at: index) } if tasks[index].status == .downloaded || tasks[index].status == .failed { tasks[index].status = .downloaded await uploadTask(at: index) } } } /// 同步相机内已有照片到任务列表(不自动上传)。 func syncExistingPhotos() async { guard cameraService.connectionState.isConnected else { return } do { let assets = try await cameraService.listAssets() for asset in assets { await handleNewAsset(asset, skipIfExists: true) } } catch { CameraTetheringLogger.log("syncExistingPhotos 失败: \(error.localizedDescription)", logger: CameraTetheringLogger.transfer) } } /// 清空所有传输记录及本地文件。 func clearAllTransfers() { for task in tasks { guard let localURL = task.localURL, FileManager.default.fileExists(atPath: localURL.path) else { continue } try? FileManager.default.removeItem(at: localURL) } tasks.removeAll() notify() } /// 按 assetID 查找任务。 func task(forAssetID assetID: String) -> CameraTransferTask? { tasks.first { $0.assetID == assetID } } private func handleNewAsset(_ asset: CameraAsset, skipIfExists: Bool) async { if skipIfExists, let existing = tasks.first(where: { $0.assetID == asset.id }), existing.status == .uploaded || existing.status == .downloaded { return } if inFlightAssetIDs.contains(asset.id) { return } if tasks.contains(where: { $0.assetID == asset.id && $0.status == .downloading }) { return } inFlightAssetIDs.insert(asset.id) defer { inFlightAssetIDs.remove(asset.id) } let taskIndex: Int if let existingIndex = tasks.firstIndex(where: { $0.assetID == asset.id }) { taskIndex = existingIndex tasks[taskIndex].status = .downloading tasks[taskIndex].errorMessage = nil } else { let task = CameraTransferTask(assetID: asset.id, filename: asset.filename, status: .downloading) tasks.insert(task, at: 0) taskIndex = 0 } notify() await downloadAsset(at: taskIndex, asset: asset) if uploadEnabled, uploadSink != nil, tasks[taskIndex].status == .downloaded { await processUploadQueue() } } private func downloadAsset(at index: Int, asset: CameraAsset? = nil) async { guard tasks.indices.contains(index) else { return } let assetID = tasks[index].assetID do { let resolvedAsset: CameraAsset if let asset { resolvedAsset = asset } else if let cached = try? await cameraService.listAssets().first(where: { $0.id == assetID }) { resolvedAsset = cached } else { throw CameraServiceError.assetNotFound } let localURL = try await cameraService.downloadAsset(resolvedAsset) let destination: URL if localURL.path.hasPrefix(CameraDownloadStorage.downloadsDirectory.path) { destination = localURL } else { destination = CameraDownloadStorage.uniqueLocalURL(for: resolvedAsset.filename) if FileManager.default.fileExists(atPath: destination.path) { try FileManager.default.removeItem(at: destination) } try FileManager.default.moveItem(at: localURL, to: destination) } tasks[index].localPath = destination.path tasks[index].status = .downloaded tasks[index].progress = 0 notify() } catch { tasks[index].status = .failed tasks[index].errorMessage = error.localizedDescription notify() } } private func processUploadQueue() async { guard uploadEnabled, uploadSink != nil else { return } guard !isProcessing else { return } isProcessing = true defer { isProcessing = false } let pendingIndices = tasks.indices.filter { tasks[$0].status == .downloaded } for index in pendingIndices { await uploadTask(at: index) } } private func uploadTask(at index: Int) async { guard tasks.indices.contains(index), let sink = uploadSink, let localURL = tasks[index].localURL else { return } tasks[index].status = .uploading tasks[index].errorMessage = nil notify() let fileType = tasks[index].filename.lowercased().hasSuffix(".mp4") ? 1 : 2 var attempt = 0 while attempt < maxRetries { do { let remoteURL = try await sink.upload( localURL: localURL, fileName: tasks[index].filename, fileType: fileType, progress: { [weak self] progress in Task { @MainActor in guard let self, self.tasks.indices.contains(index) else { return } self.tasks[index].progress = progress self.notify() } } ) tasks[index].status = .uploaded tasks[index].remoteURL = remoteURL tasks[index].progress = 100 notify() return } catch { attempt += 1 if attempt >= maxRetries { tasks[index].status = .failed tasks[index].errorMessage = error.localizedDescription notify() } else { let delay = UInt64(pow(2.0, Double(attempt))) try? await Task.sleep(nanoseconds: delay * 1_000_000_000) } } } } private func notify() { onTasksUpdated?(tasks) } }