feat: add travel album OTG import flow

This commit is contained in:
2026-07-08 09:24:51 +08:00
parent 00bda390e8
commit 92fcad7ac9
42 changed files with 6826 additions and 89 deletions

View File

@ -0,0 +1,58 @@
//
// NikonCameraDriver.swift
// otg_swift
//
// Created by hanqiu on 2026/7/2.
//
import Foundation
@preconcurrency import ImageCaptureCore
/// DriverImageCaptureCore 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
}
}

View File

@ -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
}
}
}