feat: add travel album OTG import flow
This commit is contained in:
@ -0,0 +1,58 @@
|
||||
//
|
||||
// NikonCameraDriver.swift
|
||||
// otg_swift
|
||||
//
|
||||
// Created by hanqiu on 2026/7/2.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@preconcurrency import ImageCaptureCore
|
||||
|
||||
/// 尼康相机 Driver:ImageCaptureCore 枚举 SD 卡历史照片并下载(含 NEF/JPEG 等)。
|
||||
final class NikonCameraDriver: CameraDriver {
|
||||
private let device: ICCameraDevice
|
||||
let platform: CameraPlatform = .nikon
|
||||
let deviceInfo: CameraDeviceInfo
|
||||
|
||||
init(device: ICCameraDevice, deviceInfo: CameraDeviceInfo) {
|
||||
self.device = device
|
||||
self.deviceInfo = deviceInfo
|
||||
OTGLog.info(.nikon, "initialized, device=\(deviceInfo.name)")
|
||||
}
|
||||
|
||||
func connect() async throws {
|
||||
OTGLog.debug(.nikon, "connect called")
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
OTGLog.info(.nikon, "driver released, device=\(deviceInfo.name)")
|
||||
}
|
||||
|
||||
func listObjects() async throws -> [CameraObject] {
|
||||
let mediaFilesCount = device.mediaFiles?.count ?? 0
|
||||
let contentsCount = device.contents?.count ?? 0
|
||||
let files = ICCameraItemScanner.collectImageFiles(from: device)
|
||||
OTGLog.info(.nikon, "listObjects: mediaFiles=\(mediaFilesCount), contents=\(contentsCount), images=\(files.count)")
|
||||
|
||||
let results = await ICCameraItemScanner.cameraObjects(from: files)
|
||||
.sorted { $0.capturedAt > $1.capturedAt }
|
||||
|
||||
OTGLog.info(.nikon, "listObjects: image count=\(results.count)")
|
||||
return results
|
||||
}
|
||||
|
||||
func requestThumbnailData(for object: CameraObject, maxPixelSize: Int) async -> Data? {
|
||||
await ICCameraThumbnailLoader.loadThumbnailData(for: object, from: device, maxPixelSize: maxPixelSize)
|
||||
}
|
||||
|
||||
func downloadObject(_ object: CameraObject, to directory: URL) async throws -> URL {
|
||||
guard let file = ICCameraItemScanner.findFile(matching: object, in: device) else {
|
||||
OTGLog.error(.nikon, "downloadObject file not found: \(object.filename)")
|
||||
throw CameraError.deviceNotFound
|
||||
}
|
||||
|
||||
let url = try await ICCameraFileDownloader.download(file, to: directory)
|
||||
OTGLog.info(.nikon, "downloadObject saved: \(url.lastPathComponent)")
|
||||
return url
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,210 @@
|
||||
//
|
||||
// NikonCatalogLiveCaptureService.swift
|
||||
// otg_swift
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@preconcurrency import ImageCaptureCore
|
||||
|
||||
/// 尼康边拍边传:监听 ImageCaptureCore catalog 新增文件并下载写入相册。
|
||||
/// Z6 系列等在快门后会触发 `didAdd`,无需 Nikon 专用 PTP 协议。
|
||||
final class NikonCatalogLiveCaptureService {
|
||||
|
||||
private weak var camera: ICCameraDevice?
|
||||
private(set) var isReady = false
|
||||
|
||||
private var albumID: Int?
|
||||
private let photoRepository: PhotoRepositoryProtocol
|
||||
var onShotSaved: ((String) -> Void)?
|
||||
|
||||
private var catalogPollTimer: Timer?
|
||||
private var isDownloading = false
|
||||
|
||||
private var lastDownloadedSignature: String?
|
||||
private var knownCatalogIDs: Set<String> = []
|
||||
private var catalogBaselineEstablished = false
|
||||
private var savedShotCount = 0
|
||||
|
||||
private let catalogPollInterval: TimeInterval = 1.5
|
||||
private var catalogScanCount = 0
|
||||
|
||||
init(photoRepository: PhotoRepositoryProtocol = TravelAlbumOTGPhotoRepository()) {
|
||||
self.photoRepository = photoRepository
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
func start(camera: ICCameraDevice, albumID: Int?) {
|
||||
stop()
|
||||
|
||||
self.camera = camera
|
||||
self.albumID = albumID
|
||||
self.isReady = true
|
||||
self.lastDownloadedSignature = nil
|
||||
self.knownCatalogIDs = []
|
||||
self.catalogBaselineEstablished = false
|
||||
self.savedShotCount = 0
|
||||
self.catalogScanCount = 0
|
||||
|
||||
establishCatalogBaseline(from: camera)
|
||||
|
||||
OTGLog.info(.nikon, "Catalog live capture started, album=\(albumID.map(String.init) ?? "nil")")
|
||||
startCatalogPolling()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
isReady = false
|
||||
if Thread.isMainThread {
|
||||
stopCatalogPolling()
|
||||
} else {
|
||||
DispatchQueue.main.sync { stopCatalogPolling() }
|
||||
}
|
||||
isDownloading = false
|
||||
knownCatalogIDs = []
|
||||
catalogBaselineEstablished = false
|
||||
albumID = nil
|
||||
camera = nil
|
||||
}
|
||||
|
||||
// MARK: - Catalog
|
||||
|
||||
func handleCatalogItemsAdded(_ items: [ICCameraItem]) {
|
||||
guard isReady, catalogBaselineEstablished else { return }
|
||||
|
||||
for item in items {
|
||||
guard let file = item as? ICCameraFile else { continue }
|
||||
Task { await downloadCatalogFileIfNew(file, reason: "didAdd") }
|
||||
}
|
||||
}
|
||||
|
||||
private func establishCatalogBaseline(from camera: ICCameraDevice) {
|
||||
var known = Set<String>()
|
||||
var newDuringConnect = 0
|
||||
|
||||
for file in ICCameraItemScanner.collectImageFiles(from: camera) {
|
||||
if file.wasAddedAfterContentCatalogCompleted {
|
||||
newDuringConnect += 1
|
||||
Task { await downloadCatalogFileIfNew(file, reason: "baseline-new") }
|
||||
} else {
|
||||
known.insert(ICCameraItemScanner.cameraObjectID(for: file))
|
||||
}
|
||||
}
|
||||
|
||||
knownCatalogIDs = known
|
||||
catalogBaselineEstablished = true
|
||||
OTGLog.info(.nikon, "Catalog baseline: known=\(known.count), newDuringConnect=\(newDuringConnect)")
|
||||
}
|
||||
|
||||
private func scanCatalogForNewFiles() async {
|
||||
guard isReady, catalogBaselineEstablished, let camera else { return }
|
||||
|
||||
let files = ICCameraItemScanner.collectImageFiles(from: camera)
|
||||
catalogScanCount += 1
|
||||
if catalogScanCount == 1 || catalogScanCount % 10 == 0 {
|
||||
OTGLog.debug(.nikon, "Catalog scan #\(catalogScanCount): files=\(files.count), known=\(knownCatalogIDs.count)")
|
||||
}
|
||||
|
||||
for file in files {
|
||||
await downloadCatalogFileIfNew(file, reason: "catalog-poll")
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadCatalogFileIfNew(_ file: ICCameraFile, reason: String) async {
|
||||
guard isReady else { return }
|
||||
|
||||
let objectID = ICCameraItemScanner.cameraObjectID(for: file)
|
||||
guard !knownCatalogIDs.contains(objectID) else { return }
|
||||
knownCatalogIDs.insert(objectID)
|
||||
|
||||
if isDownloading {
|
||||
knownCatalogIDs.remove(objectID)
|
||||
return
|
||||
}
|
||||
|
||||
isDownloading = true
|
||||
defer { isDownloading = false }
|
||||
|
||||
let filename = file.name ?? UUID().uuidString
|
||||
if objectID == lastDownloadedSignature {
|
||||
return
|
||||
}
|
||||
|
||||
OTGLog.info(.nikon, "\(reason): downloading \(filename) id=\(objectID)")
|
||||
|
||||
do {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("nikon-live", isDirectory: true)
|
||||
let url = try await ICCameraFileDownloader.download(file, to: tempDir)
|
||||
let data = try Data(contentsOf: url)
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
|
||||
lastDownloadedSignature = objectID
|
||||
|
||||
guard await saveToAlbum(data: data, filename: filename, reason: reason) else {
|
||||
knownCatalogIDs.remove(objectID)
|
||||
return
|
||||
}
|
||||
|
||||
savedShotCount += 1
|
||||
OTGLog.info(.nikon, "Live shot saved (#\(savedShotCount)): \(filename), bytes=\(data.count)")
|
||||
onShotSaved?(filename)
|
||||
} catch {
|
||||
knownCatalogIDs.remove(objectID)
|
||||
OTGLog.error(.nikon, "\(reason): catalog download failed \(filename): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Polling
|
||||
|
||||
private func startCatalogPolling() {
|
||||
let schedule = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.stopCatalogPolling()
|
||||
OTGLog.info(.nikon, "Catalog polling started (interval=\(self.catalogPollInterval)s)")
|
||||
|
||||
let timer = Timer(timeInterval: self.catalogPollInterval, repeats: true) { [weak self] _ in
|
||||
guard let self, self.isReady else { return }
|
||||
Task { await self.scanCatalogForNewFiles() }
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
self.catalogPollTimer = timer
|
||||
}
|
||||
|
||||
if Thread.isMainThread {
|
||||
schedule()
|
||||
} else {
|
||||
DispatchQueue.main.async(execute: schedule)
|
||||
}
|
||||
}
|
||||
|
||||
private func stopCatalogPolling() {
|
||||
catalogPollTimer?.invalidate()
|
||||
catalogPollTimer = nil
|
||||
}
|
||||
|
||||
// MARK: - Save
|
||||
|
||||
private func saveToAlbum(data: Data, filename: String, reason: String) async -> Bool {
|
||||
guard let albumID else {
|
||||
OTGLog.warning(.nikon, "\(reason): live transfer album not configured, skip save")
|
||||
return false
|
||||
}
|
||||
|
||||
do {
|
||||
let fileURL = try AlbumPhotoStorage.writeImage(
|
||||
data,
|
||||
filename: filename,
|
||||
albumID: albumID
|
||||
)
|
||||
try photoRepository.addPhoto(
|
||||
albumID: albumID,
|
||||
localPath: fileURL.path,
|
||||
createdAt: Date()
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
OTGLog.error(.nikon, "\(reason): save failed: \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user