Files
suixinkan_ios_new/suixinkan/Features/TravelAlbum/Services/WiredCameraTransferPipeline.swift
汉秋 be9c844bd3 将 otg_swift OTG 引擎迁入 Core/CameraOTG,并接入旅拍有线传图与历史导入。
恢复 USB 相机连接、边拍边传、三品牌驱动与 SwiftUI 历史导入页,通过适配层对接现有本地上传与 OSS 管道。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 11:22:07 +08:00

336 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// WiredCameraTransferPipeline.swift
// suixinkan
//
import Foundation
/// 线 USB/OTG
@MainActor
final class WiredCameraTransferPipeline {
var onTasksUpdated: (([CameraTransferTask]) -> Void)?
private(set) var tasks: [CameraTransferTask] = []
private var uploadSink: (any CameraAssetUploadSink)?
private var uploadEnabled = true
private var downloadScope = CameraDownloadStorage.Scope(accountKey: "unscoped", albumID: 0)
private var activeUploadAssetIDs: Set<String> = []
private var autoUploadEligibleAssetIDs: Set<String> = []
private var pendingDeferredNotifyTask: Task<Void, Never>?
private var lastDeferredNotifyDate = Date.distantPast
private var lastNotifiedProgressByAssetID: [String: Int] = [:]
private let maxRetries = 3
private let deferredNotifyInterval: TimeInterval = 0.15
private let maxConcurrentUploads = 3
private var isUIAttached = false
/// asset ID
private(set) var excludedAssetIDs: Set<String> = []
/// UI
func attachToUI(onTasksUpdated: @escaping ([CameraTransferTask]) -> Void) {
isUIAttached = true
self.onTasksUpdated = onTasksUpdated
}
/// UI
func detachFromUI() {
guard isUIAttached else { return }
isUIAttached = false
pendingDeferredNotifyTask?.cancel()
pendingDeferredNotifyTask = nil
activeUploadAssetIDs.removeAll()
onTasksUpdated = nil
}
/// Sink
func configure(
uploadSink: (any CameraAssetUploadSink)?,
uploadEnabled: Bool,
downloadScope: CameraDownloadStorage.Scope? = nil
) {
self.uploadSink = uploadSink
self.uploadEnabled = uploadEnabled
if let downloadScope {
self.downloadScope = downloadScope
}
}
///
func retryFailedUploads() async {
guard uploadEnabled, uploadSink != nil else { return }
let failedAssetIDs = tasks.filter { $0.status == .failed }.map(\.assetID)
for index in tasks.indices where tasks[index].status == .failed {
tasks[index].status = .downloaded
tasks[index].errorMessage = nil
}
notify()
await uploadAssets(withIDs: failedAssetIDs)
}
/// asset
func uploadAssets(withIDs assetIDs: [String]) async {
guard uploadSink != nil else { return }
var seenAssetIDs: Set<String> = []
let uniqueAssetIDs = assetIDs.filter { seenAssetIDs.insert($0).inserted }
var currentIndex = 0
while currentIndex < uniqueAssetIDs.count {
let upperBound = min(currentIndex + maxConcurrentUploads, uniqueAssetIDs.count)
let batch = uniqueAssetIDs[currentIndex ..< upperBound]
let uploadTasks = batch.map { assetID in
Task { @MainActor in
await self.uploadAssetIfNeeded(assetID: assetID)
}
}
for task in uploadTasks {
await task.value
}
currentIndex = upperBound
}
}
///
func retryUpload(assetID: String, filename: String, localPath: String) async {
guard uploadSink != nil else { return }
guard !activeUploadAssetIDs.contains(assetID) else { return }
if let index = tasks.firstIndex(where: { $0.assetID == assetID }) {
if tasks[index].status == .uploaded { return }
if tasks[index].localPath?.isEmpty != false, !localPath.isEmpty {
tasks[index].localPath = localPath
}
tasks[index].status = .downloaded
tasks[index].errorMessage = nil
} else {
guard !localPath.isEmpty,
let localURL = CameraDownloadStorage.resolveLocalURL(from: localPath),
FileManager.default.fileExists(atPath: localURL.path) else {
return
}
tasks.insert(
CameraTransferTask(
assetID: assetID,
filename: filename,
localPath: localPath,
capturedAt: CameraTransferTask.capturedAtString(forLocalPath: localPath),
status: .downloaded
),
at: 0
)
}
notify()
await uploadTask(assetID: assetID)
}
/// assetID
func task(forAssetID assetID: String) -> CameraTransferTask? {
tasks.first { $0.assetID == assetID }
}
/// asset ID
func setExcludedAssetIDs(_ ids: Set<String>) {
excludedAssetIDs = ids
}
/// asset
func removeAsset(assetID: String) {
guard !assetID.isEmpty else { return }
excludedAssetIDs.insert(assetID)
tasks.removeAll { $0.assetID == assetID }
autoUploadEligibleAssetIDs.remove(assetID)
activeUploadAssetIDs.remove(assetID)
lastNotifiedProgressByAssetID.removeValue(forKey: assetID)
}
///
func ingestDownloadedAsset(
assetID: String,
filename: String,
localPath: String,
capturedAt: Date?,
autoUpload: Bool
) {
guard !excludedAssetIDs.contains(assetID) else { return }
let capturedAtString = capturedAt.map { CameraTransferTask.capturedAtString(from: $0) }
?? CameraTransferTask.capturedAtString(forLocalPath: localPath)
if let index = tasks.firstIndex(where: { $0.assetID == assetID }) {
tasks[index].localPath = localPath
if tasks[index].capturedAt?.isEmpty != false {
tasks[index].capturedAt = capturedAtString
}
if tasks[index].status != .uploaded {
tasks[index].status = .downloaded
}
tasks[index].errorMessage = nil
} else {
tasks.insert(
CameraTransferTask(
assetID: assetID,
filename: filename,
localPath: localPath,
capturedAt: capturedAtString,
status: .downloaded
),
at: 0
)
}
if autoUpload, uploadEnabled, uploadSink != nil {
autoUploadEligibleAssetIDs.insert(assetID)
processUploadQueue()
}
notify()
}
/// asset
func markDownloading(assetID: String, filename: String) {
guard !excludedAssetIDs.contains(assetID) else { return }
if let index = tasks.firstIndex(where: { $0.assetID == assetID }) {
tasks[index].status = .downloading
tasks[index].errorMessage = nil
} else {
tasks.insert(
CameraTransferTask(assetID: assetID, filename: filename, status: .downloading),
at: 0
)
}
notify()
}
/// asset
func failDownload(assetID: String, message: String) {
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
tasks[index].status = .failed
tasks[index].errorMessage = message
notify()
}
///
private func processUploadQueue() {
guard uploadSink != nil else { return }
while activeUploadAssetIDs.count < maxConcurrentUploads,
let task = tasks.first(where: {
$0.status == .downloaded
&& !activeUploadAssetIDs.contains($0.assetID)
&& autoUploadEligibleAssetIDs.contains($0.assetID)
}) {
startConcurrentUpload(assetID: task.assetID)
}
}
private func startConcurrentUpload(assetID: String) {
guard !activeUploadAssetIDs.contains(assetID) else { return }
activeUploadAssetIDs.insert(assetID)
Task { @MainActor in
await self.uploadTask(assetID: assetID)
self.activeUploadAssetIDs.remove(assetID)
self.processUploadQueue()
}
}
private func uploadAssetIfNeeded(assetID: String) async {
if activeUploadAssetIDs.contains(assetID) { return }
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
if tasks[index].status == .uploaded { return }
activeUploadAssetIDs.insert(assetID)
defer { activeUploadAssetIDs.remove(assetID) }
guard let refreshedIndex = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
if tasks[refreshedIndex].status == .downloaded || tasks[refreshedIndex].status == .failed {
tasks[refreshedIndex].status = .downloaded
tasks[refreshedIndex].errorMessage = nil
await uploadTask(assetID: assetID)
}
}
private func uploadTask(assetID: String) async {
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }),
let sink = uploadSink,
let localURL = tasks[index].localURL
else { return }
tasks[index].status = .uploading
tasks[index].errorMessage = nil
notify()
let filename = tasks[index].filename
let fileType = filename.lowercased().hasSuffix(".mp4") ? 1 : 2
var attempt = 0
while attempt < maxRetries {
do {
let remoteURL = try await sink.upload(
localURL: localURL,
fileName: filename,
fileType: fileType,
progress: { [weak self] progress in
Task { @MainActor in
guard let self,
let taskIndex = self.tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
let lastNotifiedProgress = self.lastNotifiedProgressByAssetID[assetID] ?? -1
guard progress == 100 || progress - lastNotifiedProgress >= 10 else { return }
self.lastNotifiedProgressByAssetID[assetID] = progress
self.tasks[taskIndex].progress = progress
self.notify(progress == 100 ? .immediate : .deferred)
}
}
)
guard let taskIndex = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
tasks[taskIndex].status = .uploaded
tasks[taskIndex].remoteURL = remoteURL
tasks[taskIndex].progress = 100
autoUploadEligibleAssetIDs.remove(assetID)
lastNotifiedProgressByAssetID.removeValue(forKey: assetID)
notify()
return
} catch {
attempt += 1
guard let taskIndex = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
if attempt >= maxRetries {
tasks[taskIndex].status = .failed
tasks[taskIndex].errorMessage = error.localizedDescription
lastNotifiedProgressByAssetID.removeValue(forKey: assetID)
notify()
} else {
let delay = UInt64(pow(2.0, Double(attempt)))
try? await Task.sleep(nanoseconds: delay * 1_000_000_000)
}
}
}
}
private func notify(_ mode: WiredTransferNotifyMode = .immediate) {
switch mode {
case .immediate:
pendingDeferredNotifyTask?.cancel()
pendingDeferredNotifyTask = nil
onTasksUpdated?(tasks)
case .deferred:
let elapsed = Date().timeIntervalSince(lastDeferredNotifyDate)
if elapsed >= deferredNotifyInterval {
lastDeferredNotifyDate = Date()
onTasksUpdated?(tasks)
return
}
guard pendingDeferredNotifyTask == nil else { return }
let delay = UInt64((deferredNotifyInterval - elapsed) * 1_000_000_000)
pendingDeferredNotifyTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: delay)
guard !Task.isCancelled else { return }
self.lastDeferredNotifyDate = Date()
self.pendingDeferredNotifyTask = nil
self.onTasksUpdated?(self.tasks)
}
}
}
}
private enum WiredTransferNotifyMode {
case immediate
case deferred
}